Merge "Revert "Log a warning message if project has SRs but no results existed on merge""
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 1a535ef..d1f2786 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -256,8 +256,8 @@
 if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
 the password in the request is first checked against the HTTP password and, if
 it does not match, it is then validated against the LDAP password.
-Service users that only exist in the Gerrit database are authenticated by their
-HTTP passwords.
+Service users that are link:cmd-create-account.html[internal-only] are
+authenticated by their HTTP passwords.
 
 * `LDAP_BIND`
 +
@@ -611,7 +611,7 @@
 single call would trigger a full LDAP authentication and groups resolution
 which could introduce a noticeable latency on the overall execution
 and produce unwanted load to the LDAP server.
-+
+
 [[auth.gitOAuthProvider]]auth.gitOAuthProvider::
 +
 Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
@@ -714,8 +714,10 @@
 [[auth.autoUpdateAccountActiveStatus]]auth.autoUpdateAccountActiveStatus::
 +
 Whether to allow automatic synchronization of an account's inactive flag upon login.
++
 If set to true, upon login, if the authentication back-end reports the account as active,
-the account's inactive flag in the internal Gerrit database will be updated to be active.
+the account's inactive flag in NoteDb will be updated to be active.
++
 If the authentication back-end reports the account as inactive, the account's flag will be
 updated to be inactive and the login attempt will be blocked. Users enabling this feature
 should ensure that their authentication back-end is supported. Currently, only
@@ -979,8 +981,8 @@
 this cache should be disabled in a cluster setup using multiple primary
 or multiple replica nodes.
 +
-The cache should be flushed whenever the database changes table is modified
-outside of Gerrit.
+The cache should be flushed whenever NoteDb change metadata in a repository is
+modified outside of Gerrit.
 
 cache `"git_modified_files"`::
 +
@@ -1338,7 +1340,7 @@
 Whether the first user that logs in to the Gerrit server should
 automatically be added to the administrator group and hence get the
 `administrateServer` capability assigned. This is useful to bootstrap
-the authentication database.
+the link:config-accounts.html[account data].
 +
 Default is true.
 
@@ -2303,7 +2305,7 @@
 By default unset, as the HTTP daemon must be configured externally
 by the system administrator, and might not even be running on the
 same host as Gerrit.
-+
+
 [[gerrit.installBatchModule]]gerrit.installBatchModule::
 +
 Repeatable list of class name of additional Guice modules to load as
@@ -2313,7 +2315,7 @@
 located under the `/lib` directory.
 +
 By default unset.
-+
+
 [[gerrit.installDbModule]]gerrit.installDbModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -2593,8 +2595,9 @@
 set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit,
-`${file}` for the file name and `${commit}` for the SHA-1 hash for
-the commit.
+`${file}` for the file name, `${hash}` for the SHA-1 hash for the commit,
+and `${commit}` for the change ref or SHA-1 of the commit if no base
+patch set.
 
 [[gitweb.filehistory]]gitweb.filehistory::
 +
@@ -4150,18 +4153,6 @@
 +
 Default is 5 minutes.
 
-[[receive.changeUpdateThreads]]receive.changeUpdateThreads::
-+
-Number of threads to perform change creation or patch set updates
-concurrently. Each thread uses its own database connection from
-the database connection pool, and if all threads are busy then
-main receive thread will also perform a change creation or patch
-set update.
-+
-Defaults to 1, using only the main receive thread. This feature is for
-databases with very high latency that can benefit from concurrent
-operations when multiple changes are impacted at once.
-
 [[receive.checkMagicRefs]]receive.checkMagicRefs::
 +
 If true, Gerrit will verify the destination repository has
@@ -4403,9 +4394,8 @@
 
 [[repository.name.ownerGroup]]repository.<name>.ownerGroup::
 +
-A name of a group which exists in the database. Zero, one or many
-groups are allowed.  Each on its own line.  Groups which don't exist
-in the database are ignored.
+A name of a link:config-groups.html[group] which exists. Zero, one or many
+groups are allowed.  Each on its own line.  Groups which don't exist are ignored.
 
 [[retry]]
 === Section retry
@@ -5234,6 +5224,7 @@
 used for suggesting accounts when adding members to a group.
 +
 By default 0.
+
 [[suggest.relevantChanges]]suggest.relevantChanges::
 +
 When suggesting reviewers, we go over recent changes of the user, and
@@ -5265,13 +5256,13 @@
 end of the request the performance events are handed over to the
 link:dev-plugins.html#performance-logger[PerformanceLogger] plugins.
 This means if performance logging is enabled, the memory footprint of
-requests is slightly increased.
+requests can be markedly increased.
+In one recorded case the impact was an overall heap increase of 40%
+(using the metrics-reporter-graphite plugin), in other instances the
+heap increase wasn't nearly as dramatic and the impact is most likely
+dependent on which plugin is used.
 +
-This setting has no effect if no
-link:dev-plugins.html#performance-logger[PerformanceLogger] plugins are
-installed, because then performance logging is always disabled.
-+
-By default, true.
+By default, false.
 
 [[tracing.exportPerformanceMetrics]]tracing.exportPerformanceMetrics::
 +
@@ -5744,10 +5735,6 @@
 [auth]
   registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
 
-[database]
-  username = webuser
-  password = s3kr3t
-
 [ldap]
   password = l3tm3srch
 
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index 46c9ced..00e33a3 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -268,7 +268,22 @@
 
 cgit can be used by specifying `gitweb.type` to be 'cgit'.
 
-It is also possible to define custom patterns.
+It is also possible to define custom patterns. Gitea can be used
+with custom patterns for example:
+
+----
+  git config -f $site_path/etc/gerrit.config gitweb.type custom
+  git config -f $site_path/etc/gerrit.config gitweb.urlEncode false
+  git config -f $site_path/etc/gerrit.config gitweb.linkname gitea
+  git config -f $site_path/etc/gerrit.config gitweb.url https://gitea.example.org/
+  git config -f $site_path/etc/gerrit.config gitweb.branch ${project}/src/branch/${branch}
+  git config -f $site_path/etc/gerrit.config gitweb.file ${project}/src/commit/${hash}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.filehistory ${project}/commits/branch/${branch}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.project ${project}
+  git config -f $site_path/etc/gerrit.config gitweb.revision ${project}/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.roottree ${project}/src/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.tag ${project}/src/tag/${tag}
+----
 
 === SEE ALSO
 
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index c62b626..05e32ab 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -21,7 +21,7 @@
 
 The range of values is:
 
-* -2 This shall not be merged
+* -2 This shall not be submitted
 +
 The code is so horribly incorrect/buggy/broken that it must not be
 submitted to this project, or to this branch.  This value is valid
@@ -31,7 +31,7 @@
 +
 *Any -2 blocks submit.*
 
-* -1 I would prefer this is not merged as is
+* -1 I would prefer this is not submitted as is
 +
 The code doesn't look right, or could be done differently, but
 the reviewer is willing to live with it as-is if another reviewer
@@ -64,8 +64,8 @@
 
 For a change to be submittable, the latest patch set must have a
 `+2 Looks good to me, approved` in this category, and no
-`-2 Do not submit`.  Thus `-2` on any patch set can block a submit,
-while `+2` on the latest patch set can enable it.
+`-2 This shall not be submitted`.  Thus `-2` on any patch set can
+block a submit, while `+2` on the latest patch set can enable it.
 
 If a Gerrit installation does not wish to use this label in any project,
 the `[label "Code-Review"]` section can be deleted from `project.config`
@@ -278,11 +278,10 @@
 [[label_copyAnyScore]]
 === `label.Label-Name.copyAnyScore`
 
-*DEPRECATED: use `is:ANY` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
+*DEPRECATED:* Use the link:#is_any[is:ANY] predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead.
 
-If true, any score for the label is copied forward when a new patch
-set is uploaded. Defaults to false.
+Defaults to false.
 
 [[label_copyCondition]]
 === `label.Label-Name.copyCondition`
@@ -296,13 +295,109 @@
 
 Gerrit currently supports the following predicates:
 
-==== changekind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+[[changekind]]
+==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE}
 
-Matches if the diff between two patch sets was of a certain change kind.
+Matches if the diff between two patch sets was of a certain change kind:
 
+* [[no_change]]`NO_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree,
+code delta, and commit message as the previous patch set. This means
+that only the patch set SHA-1 is different. This can be used to enable
+sticky approvals, reducing turn-around for this special case.
++
+It is recommended to leave this enabled for both, the Code-Review and
+the Verified labels.
++
+`NO_CHANGE` is more trivial than a trivial rebase, no code change and
+a first parent update, hence this change kind is also matched by
+`changekind:TRIVIAL_REBASE`, `changekind:NO_CODE_CHANGE` and if it's
+a merge commit by `changekind:MERGE_FIRST_PARENT_UPDATE`.
+
+
+* [[no_code_change]]`NO_CODE_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree
+as the previous patch set and the same code diff (including context
+lines) as the previous patch set. This means only the commit message
+may be different; the change hasn't even been rebased. Also matches if
+the commit message is not different, which means this includes matching
+patch sets that have `NO_CHANGE` as the change kind.
++
+This predicate can be used to enable sticky approvals on labels that
+only depend on the code, reducing turn-around if only the commit
+message is changed prior to submitting a change.
++
+For the Verified label that is optionally installed by the
+link:pgm-init.html[init] site program this predicate is used by
+default.
+
+* [[merge_first_parent_update]]`MERGE_FIRST_PARENT_UPDATE`:
++
+Matches when a new patch set is uploaded that is a new merge commit
+which only differs from the merge commit in the previous patch set in
+its first parent, or has identical parents (aka the change kind of the
+merge commit is `NO_CHANGE`).
++
+The first parent of the merge commit is part of the change's target
+branch, whereas the other parent(s) refer to the feature branch(es) to
+be merged.
++
+Matching this change kind is useful if you don't want to trigger CI or
+human verification again if your target branch moved on but the feature
+branch(es) being merged into the target branch did not change.
++
+This predicate does not match if the patch set is not a merge commit.
+
+* [[trivial_rebase]]`TRIVIAL_REBASE`:
++
+Matches when a new patch set is uploaded that is a trivial rebase. A
+new patch set is considered to be trivial rebase if the commit message
+is the same as in the previous patch set and if it has the same diff
+(including context lines) as the previous patch set. This is the case
+if the change was rebased onto a different parent and that rebase did
+not require git to perform any conflict resolution, or if the parent
+did not change at all (aka the change kind of the commit is
+`NO_CHANGE`).
++
+This predicate can be used to enable sticky approvals, reducing
+turn-around for trivial rebases prior to submitting a change.
++
+For the pre-installed Code-Review label this predicate is used by
+default.
+
+* [[rework]]`REWORK`:
++
+Matches all kind of change kinds because any other change kind
+is just a more trivial version of a rework. This means setting
+`changekind:REWORK` is equivalent to setting `is:ANY`.
+
+[[is_magic]]
 ==== is:{MIN,MAX,ANY}
 
-Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+Matches approvals that have a minimal, maximal or any score:
+
+* [[is_min]]`MIN`:
++
+Matches approvals that have a minimal score, i.e. the lowest possible
+(negative) value for this label.
+
+* [[is_max]]`MAX`:
++
+Matches approvals that a maximal score, i.e. the highest possible
+(positive) value for this label.
+
+* [[is_any]]`ANY`:
++
+Matches any approval when a new patch set is uploaded.
+
+[[is_value]]
+==== is:'VALUE'
+
+Matches approvals that have a voting value that is equal to 'VALUE'.
+
+Negative values need to be quoted, e.g.: is:"-1"
 
 [[approverin]]
 ==== approverin:link:#group-id[\{group-id\}]
@@ -313,14 +408,25 @@
 [[uploaderin]]
 ==== uploaderin:link:#group-id[\{group-id\}]
 
-Matches votes where the new patch set was uploaded by a member of
+Matches all votes if the new patch set was uploaded by a member of
 link:#group-id[\{group-id\}].
 
+[[has_unchanged_files]]
 ==== has:unchanged-files
 
-Matches when the new patch-set includes the same files as the old patch-set.
+Matches when the new patch-set has the same list of files as the
+previous patch-set.
 
-Only 'unchanged-files' is supported for 'has'.
+Files that are renamed in the new patch set are counted as a deletion
+of the file at the old path and an addition of the file at the new
+path. This means, if there are renames, the list of files did change
+and this predicate doesn't match.
+
+This predicate is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+Note, "unchanged-files" is the only value that is supported for the
+"has" operator.
 
 [[group-id]]
 ==== Group ID
@@ -354,122 +460,72 @@
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
-*DEPRECATED: use `is:MIN` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
+*DEPRECATED:* Use the link:#is_min[is:MIN] predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead.
 
-If true, the lowest possible negative value for the label is copied
-forward when a new patch set is uploaded. Defaults to false, except
-for All-Projects which has it true by default.
+Defaults to false.
 
 [[label_copyMaxScore]]
 === `label.Label-Name.copyMaxScore`
 
-*DEPRECATED: use `is:MAX` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
+*DEPRECATED:* Use the link:#is_max[is:MAX] predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead.
 
-If true, the highest possible positive value for the label is copied
-forward when a new patch set is uploaded. This can be used to enable
-sticky approvals, reducing turn-around for trivial cleanups prior to
-submitting a change. Defaults to false.
+Defaults to false.
 
 [[label_copyAllScoresIfListOfFilesDidNotChange]]
 === `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
 
-*DEPRECATED: use `is:ANY AND has:unchanged-files` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-This policy is useful if you don't want to trigger CI or human
-verification again if the list of files didn't change.
-
-If true, all scores for the label are copied forward when a new
-patch-set is uploaded that has the same list of files as the previous
-patch-set.
-
-Renames are considered different files when computing whether new files
-were added or old files were deleted. Hence, if there are renames, scores will
-*NOT* be copied over.
+*DEPRECATED:* Use the link:#has_unchanged_files[has:unchanged-files]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 Defaults to false.
 
 [[label_copyAllScoresOnMergeFirstParentUpdate]]
 === `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
 
-*DEPRECATED: use `is:ANY AND changekind:MERGE_FIRST_PARENT_UPDATE` predicates
-in link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-This policy is useful if you don't want to trigger CI or human
-verification again if your target branch moved on but the feature
-branch being merged into the target branch did not change. It only
-applies if the patch set is a merge commit.
-
-If true, all scores for the label are copied forward when a new
-patch set is uploaded that is a new merge commit which only
-differs from the previous patch set in its first parent, or has
-identical parents. The first parent would be the parent of the merge
-commit that is part of the change's target branch, whereas the other
-parent(s) refer to the feature branch(es) to be merged.
+*DEPRECATED:* Use the
+link:#merge_first_parent_update[changekind:MERGE_FIRST_PARENT_UPDATE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 Defaults to false.
 
 [[label_copyAllScoresOnTrivialRebase]]
 === `label.Label-Name.copyAllScoresOnTrivialRebase`
 
-*DEPRECATED: use `is:ANY AND changekind:TRIVIAL_REBASE` predicates
-in link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that is a trivial rebase. A new patch set is considered to be trivial
-rebase if the commit message is the same as in the previous patch set and if it
-has the same diff (including context lines) as the previous patch set. This is
-the case if the change was rebased onto a different parent and that rebase did
-not require git to perform any conflict resolution, or if the parent did not
-change at all.
-
-This can be used to enable sticky approvals, reducing turn-around for
-trivial rebases prior to submitting a change.
-For the pre-installed Code-Review label this is enabled by default.
+*DEPRECATED:* Use the link:#trivial_rebase[changekind:TRIVIAL_REBASE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 Defaults to false.
 
 [[label_copyAllScoresIfNoCodeChange]]
 === `label.Label-Name.copyAllScoresIfNoCodeChange`
 
-*DEPRECATED: use `is:ANY AND changekind:NO_CODE_CHANGE` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that has the same parent tree as the previous patch set and the same
-code diff (including context lines) as the previous patch set. This means only
-the commit message is different; the change hasn't even been rebased. This can
-be used to enable sticky approvals on labels that only depend on the code,
-reducing turn-around if only the commit message is changed prior to submitting a
-change. For the Verified label that is optionally installed by the
-link:pgm-init.html[init] site program this is enabled by default.
+*DEPRECATED:* Use the link:#no_code_change[changekind:NO_CODE_CHANGE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 Defaults to false.
 
 [[label_copyAllScoresIfNoChange]]
 === `label.Label-Name.copyAllScoresIfNoChange`
 
-*DEPRECATED: use `is:ANY AND changekind:NO_CHANGE` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that has the same parent tree, code delta, and commit
-message as the previous patch set. This means that only the patch
-set SHA-1 is different. This can be used to enable sticky
-approvals, reducing turn-around for this special case.
-It is recommended to leave this enabled for both Verified and
-Code-Review labels.
+*DEPRECATED:* Use the link:#no_change[changekind:NO_CHANGE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 Defaults to true.
 
 [[label_copyValue]]
 === `label.Label-Name.copyValue`
 
-Value that should be copied forward when a new patch set is uploaded.
-This can be used to enable sticky votes. Can be specified multiple
-times. By default not set.
+*DEPRECATED:* Use the link:#is_value[is:<value>] predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead.
+
+By default not set.
 
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 85006dc..bc45956 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -221,6 +221,10 @@
 The original subject limited to 72 characters, with an ellipsis if it exceeds
 that.
 
+$change.sizeBucket::
++
+Human-readable size bucket of the current change.
+
 $change.ownerEmail::
 +
 The email address of the owner of the change.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index ea8f6d2..9fd5b1b 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -142,7 +142,7 @@
 [[receive.requireContributorAgreement]]receive.requireContributorAgreement::
 +
 Controls whether or not a user must complete a contributor agreement before
-they can upload changes. Default is `INHERIT`. If `All-Project` enables this
+they can upload changes. Default is `INHERIT`. If `All-Projects` enables this
 option then the dependent project must set it to false if users are not
 required to sign a contributor agreement prior to submitting changes for that
 specific project. To use that feature the global option in `gerrit.config`
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index dfb033c..2686f39 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -178,6 +178,12 @@
 redefine a submit requirement in a child project and make the submit requirement
 always non-applicable.
 
+[[operator_file]]
+file:"'<filePattern>',withDiffContaining='<contentPattern>'"::
++
+An operator that returns true if the latest patchset contained a modified file
+matching `<filePattern>` with a modified region matching `<contentPattern>`.
+
 [[unsupported_operators]]
 === Unsupported Operators
 
@@ -253,6 +259,47 @@
   canOverrideInChildProjects = true
 ----
 
+
+[[test-submit-requirements]]
+== Testing Submit Requirements
+
+The link:rest-api-changes.html#check-submit-requirement[Check Submit Requirement]
+change endpoint can be used to test submit requirements on any change. Users
+are encouraged to test submit requirements before adding them to the project
+to ensure they work as intended.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+    {
+      "name": "Code-Review",
+      "submittability_expression": "label:Code-Review=+2"
+    }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "status": "SATISFIED",
+    "submittability_expression_result": {
+      "expression": "label:Code-Review=+2",
+      "fulfilled": true,
+      "passingAtoms": [
+        "label:Code-Review=+2"
+      ]
+    },
+    "is_legacy": false
+  }
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index 73fb1bc..73cfc55 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -8,7 +8,7 @@
 
 The HTML header, footer, and CSS may be customized for login screens (LDAP,
 OAuth, OpenId) and the internally managed Gitweb servlet. See
-link:pg-plugin-dev.txt[JavaScript Plugin Development and API] for documentation
+link:pg-plugin-dev.html[JavaScript Plugin Development and API] for documentation
 on modifying styles for the rest of Gerrit (not login screens).
 
 At startup Gerrit reads the following files (if they exist) and
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index 6dc6f5f..efba520 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -13,7 +13,7 @@
 * Use-Cases:
   The interactions between a user and a system to attain particular
   goals.
-* Acceptance Criteria
+* Acceptance Criteria:
   Conditions that must be satisfied to consider the feature as done.
 * Background:
   Stuff one needs to know to understand the use-cases (e.g. motivating
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 802b484..1151f1c 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -173,6 +173,9 @@
 * `-Dcom.google.gerrit.scenarios.ssh_port=29418`
 * `-Dcom.google.gerrit.scenarios.http_port=8080`
 * `-Dcom.google.gerrit.scenarios.http_scheme=http`
+* `-Dcom.google.gerrit.scenarios.username=admin`
+* `-Dcom.google.gerrit.scenarios.replica_hostname=localhost`
+* `-Dcom.google.gerrit.scenarios.project_prefix=`
 
 Above, the properties can be set with values matching specific deployment topologies under test.
 The name of the property corresponds to the uppercase keyword found in the json file. For example,
@@ -194,6 +197,20 @@
 That whole replication time depends on the system under test. Therefore, this property here should
 be set to a value high enough, so that the test checks for a done replication at the right time.
 
+==== Context path
+
+The `context_path` property allows test scenarios to send Gerrit REST requests to Gerrit instances
+that use a context path in the URL. Its default is no context path and can be set using another value:
+
+* `-Dcom.google.gerrit.scenarios.context_path=/context`
+
+==== Authentication
+
+The `authenticated` property allows test scenarios to use authenticated HTTP clones. Its default is
+no authentication:
+
+* `-Dcom.google.gerrit.scenarios.authenticated=false`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/Documentation/dev-plugins-lifecycle.txt b/Documentation/dev-plugins-lifecycle.txt
index d5bd791..2f5ffb7 100644
--- a/Documentation/dev-plugins-lifecycle.txt
+++ b/Documentation/dev-plugins-lifecycle.txt
@@ -15,14 +15,14 @@
 The idea of creating a new plugin is posted and discussed on the
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] mailing list.
 +
-Also see section link#ideation_discussion[Ideation and discussion] below.
+Also see section <<ideation_discussion>> below.
 
 - Prototyping (optional):
 +
 The author of the plugin creates a working prototype on a public repository
 accessible to the community.
 +
-Also see section link#plugin_prototyping[Plugin Prototyping] below.
+Also see section <<plugin_prototyping>> below.
 
 - Proposal and Hosting:
 +
@@ -37,7 +37,7 @@
 plugins path on link:https://gerrit-review.googlesource.com[the Gerrit project
 site,role=external,window=_blank].
 +
-Also see section link#plugin_proposal[Plugin Proposal] below.
+Also see section <<plugin_proposal>> below.
 
 - Build:
 +
@@ -46,14 +46,14 @@
 link:https://gerrit-ci.gerritforge.com[the GerritForge CI,role=external,window=_blank] that build the
 plugin for each Gerrit version that it supports.
 +
-Also see section link#build[Build] below.
+Also see section <<build>> below.
 
 - Development and Contribution:
 +
 The author develops a production-ready code base of the plugin, with
 contributions, reviews, and help from the Gerrit community.
 +
-Also see section link#development_contribution[Development and contribution]
+Also see section <<development_contribution>>
 below.
 
 - Release:
@@ -62,7 +62,7 @@
 on the link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 +
-Also see section link#plugin_release[Plugin release] below.
+Also see section <<plugin_release>> below.
 
 - Maintenance:
 +
@@ -75,7 +75,7 @@
 The author declares that the plugin is not maintained anymore or is deprecated
 and should not be used anymore.
 +
-Also see section link#plugin_deprecation[Plugin deprecation] below.
+Also see section <<plugin_deprecation>> below.
 
 [[ideation_discussion]]
 == Ideation and Discussion
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 8592205..ca72f8b 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2,8 +2,9 @@
 = Gerrit Code Review - Plugin Development
 
 The Gerrit server functionality can be extended by installing plugins.
-This page describes how plugins for Gerrit can be developed and hosted
-on gerrit-review.googlesource.com.
+This page describes how plugins for Gerrit can be developed. See the overall
+link:dev-plugins-lifecycle.html[Plugin Lifecycle] document for how plugins can
+be hosted on gerrit-review.googlesource.com.
 
 For JavaScript plugin development, consult with
 link:pg-plugin-dev.html[JavaScript Plugin Development] guide.
@@ -207,6 +208,28 @@
 The canonical web URL may be injected into any .jar plugin regardless of
 whether or not the plugin provides an HTTP servlet.
 
+[[plugin_resources]]
+=== Plugin resources
+
+Plugins are able to access their own resources without having to go through
+the implementation details on how they are packaged or deployed to Gerrit.
+
+The following example shows a MyClass in a plugin that is able to access the
+last modified time of the "myresource" loaded.
+
+[source,java]
+----
+public class MyClass {
+
+  @Inject
+  public MyClass(Plugin plugin) {
+    long myresourceTime = plugin.getContentScanner().getEntry("myresource").getTime();
+  }
+
+  [...]
+}
+----
+
 [[reload_method]]
 === Reload Method
 
@@ -415,6 +438,17 @@
 +
 Publication of usage data
 
+* `com.google.gerrit.extensions.events.GitReferenceUpdatedListener`:
++
+A git reference was updated. A separate event for every ref updated in
+a BatchRefUpdate will be fired.
+
+* `com.google.gerrit.extensions.events.GitBatchRefUpdateListener`:
++
+One or more git references were updated. Alternative to GitReferenceUpdatedListener.
+A single event will inform about all refs updated by a BatchRefUpdate. Will also be
+fired, if only a single ref was updated.
+
 * `com.google.gerrit.extensions.events.GarbageCollectorListener`:
 +
 Garbage collection ran on a project
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index f045ab8..6ff064c 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,6 +178,9 @@
 NOTE: To learn why using `java -jar` isn't sufficient, see
 <<special_bazel_java_version,this explanation>>.
 
+NOTE: When launching the daemong this way, the settings from the `[container]` section from the
+`$GERRIT_SITE/etc/gerrit.config` are not honored.
+
 To debug the Gerrit server of this test site:
 
 .  Open a debug port (such as port 5005). To do so, insert the following code
@@ -188,6 +191,49 @@
 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
 ----
 
+=== Running the Daemon honoring the [container] section settings
+
+To run the Daemon and honor the `[container]` section settings use the `gerrit.sh` script:
+
+----
+  $ cd $GERRIT_SITE
+  $ bin/gerrit.sh run
+----
+
+To run the Daemon in debug mode use the `--debug` option:
+
+----
+  $ bin/gerrit.sh run --debug
+----
+
+The default debug port is `8000`. To specify a different debug port use the `--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-port=5005
+----
+
+The `--debug-address` option also exists and is a synonym for the ``--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=5005
+----
+
+Note that, by default, the debugger will only accept connections from the localhost. To enable
+debug connections from other host(s) provide also a host name or wildcard in the `--debug-address`
+value:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=*:5005
+----
+
+Debugging the Daemon startup requires starting the JVM in suspended debug mode. The JVM will await
+for a debugger to attach before proceeding with the start. Use the `--suspend` option for that
+scenario:
+
+----
+  $ bin/gerrit.sh run --debug --suspend
+----
+
 === Running the Daemon with Gerrit Inspector
 
 link:dev-inspector.html[Gerrit Inspector] is an interactive scriptable
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index a4ccccf..db08da5 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -44,15 +44,13 @@
 +
 Generate and publish a PGP key as described in
 link:http://central.sonatype.org/pages/working-with-pgp-signatures.html[
-Working with PGP Signatures,role=external,window=_blank]. In addition to the keyserver mentioned
-there it is recommended to also publish the key to the
-link:https://keyserver.ubuntu.com/[Ubuntu key server].
+Working with PGP Signatures,role=external,window=_blank].
 +
 Please be aware that after publishing your public key it may take a
 while until it is visible to the Sonatype server.
 +
 Add an entry for the public key in the
-link:https://gerrit.googlesource.com/homepage/+/md-pages/releases/public-keys.md[key list,role=external,window=_blank]
+link:https://gerrit.googlesource.com/homepage/+/master/pages/site/releases/public-keys.md[key list,role=external,window=_blank]
 on the homepage.
 +
 The PGP passphrase can be put in `~/.m2/settings.xml`:
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 6565ba4..ff37ffc 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -161,8 +161,8 @@
 * `+2 Looks good to me, approved`
 * `+1 Looks good to me, but someone else must approve`
 * `0 No score`
-* `-1 I would prefer that you didn't submit this`
-* `-2 Do not submit`
+* `-1 I would prefer this is not submitted as is`
+* `-2 This shall not be submitted`
 
 In addition, a change must have at least one `+2` vote and no `-2` votes before
 it can be submitted. These numerical values do not accumulate. Two
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 8a3b10e..26dbc37 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -316,9 +316,9 @@
 
 A useful feature on labels is the possibility to automatically copy
 scores forward to new patch sets if it was a
-link:config-labels.html#label_copyAllScoresOnTrivialRebase[trivial
-rebase] or if link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-there was no code change] (e.g. only the commit message was edited).
+link:config-labels.html#trivial_rebase[trivial rebase] or if
+link:config-labels.html#no_code_change[there was no code change] (e.g.
+only the commit message was edited).
 
 [[submit-rules]]
 == Submit Rules
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index e5966de..4b36e3e 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -42,6 +42,7 @@
 [[Apache2_0]]
 Apache2.0
 
+* auto:auto-factory
 * auto:auto-value
 * auto:auto-value-annotations
 * auto:auto-value-gson
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index c45de05..13873ed 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 9df4b04..70352dc 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -456,6 +456,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 4e93da1..8786cc4 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -24,8 +24,8 @@
 Here are some examples of open source plugins that make use of the Checks API:
 
 * link:https://gerrit.googlesource.com/plugins/checks/+/master/gr-checks/plugin.js[Gerrit Checks Plugin]
-* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/master/src/main/resources/static/buildbucket.js[Chromium Buildbucket Plugin]
-* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/master/src/main/resources/static/chromium-coverage.js[Chromium Coverage Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/main/web/plugin.ts[Chromium Buildbucket Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/main/web/plugin.ts[Chromium Coverage Plugin]
 
 [[register]]
 == register
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index dc7986f..b8a30ee 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -30,10 +30,6 @@
 });
 ```
 
-You can find more elaborate examples in the
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/[polygerrit-ui/app/samples/]
-directory of the source tree.
-
 [[low-level-api-concepts]]
 == Low-level DOM API concepts
 
@@ -95,32 +91,26 @@
 [[low-level-style]]
 === Styling DOM Elements
 
-A plugin may provide Polymer's
-https://polymer-library.polymer-project.org/3.0/docs/devguide/style-shadow-dom[style
-modules,role=external,window=_blank] to style individual endpoints using
-`plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
-as a standalone `<dom-module>` defined in the same .js file.
+Gerrit only offers customized CSS styling by setting
+link:https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_[custom_properties]
+(aka css variables).
 
-See
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/theme-plugin.js[samples/theme-plugin.js]
-for an example.
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/styles/themes/[app-theme.ts]
+for the list of available variables.
+
+Just add code like this to your JavaScript plugin:
 
 ``` js
-const styleElement = document.createElement('dom-module');
-styleElement.innerHTML =
- `<template>
-    <style>
-    html {
-      --primary-text-color: red;
-    }
-   </style>
- </template>`;
-
-styleElement.register('some-style-module');
-
-Gerrit.install(plugin => {
-  plugin.registerStyleModule('change-metadata', 'some-style-module');
-});
+    const styleEl = document.createElement('style');
+    styleEl.innerHTML = `
+        html {
+          --header-background-color: #c3d9ff;
+        }
+        html.darkTheme {
+          --header-background-color: #c3d9ff90;
+        }
+    `;
+    document.head.appendChild(styleEl);
 ```
 
 [[high-level-api-concepts]]
@@ -146,10 +136,6 @@
 binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
-See
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/bind-parameters.js[samples/bind-parameters.js]
-for an example.
-
 === hook
 `plugin.hook(endpointName, opt_options)`
 
@@ -169,7 +155,8 @@
 === registerStyleModule
 `plugin.registerStyleModule(endpointName, moduleName)`
 
-See link:#low-level-style[above].
+This API is deprecated and will be removed either in version 3.6 or 3.7,
+see link:#low-level-style[above] for an alternative.
 
 === on
 Register a JavaScript callback to be invoked when events occur within
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 586f685..560fb92 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -10,6 +10,10 @@
   -d <SITE_PATH>
   [--enable-httpd | --disable-httpd]
   [--enable-sshd | --disable-sshd]
+  [--debug]
+  [--debug-port]
+  [--debug_address]
+  [--suspend]
   [--console-log]
   [--replica]
   [--headless]
@@ -39,6 +43,17 @@
 	Enable (or disable) the internal SSH daemon, answering SSH
 	clients and remotely executed commands.  Enabled by default.
 
+--debug::
+	Start JVM in debug mode.
+
+--debug-port::
+--debug_address:
+	Specify which JVM debug port/address to use. The default debug address is 8000.
+
+--suspend::
+	Start JVM debug in suspended mode. The JVM will await for a debugger
+	to attach before proceeding with the start.
+
 --replica::
 	Run in replica mode, permitting only read operations
     by clients.  Commands which modify state such as
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 45a39d8..991f36c 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -388,7 +388,7 @@
 |`revision`           ||
 The revision of the `refs/meta/config` branch from which the access
 rights were loaded.
-|`inherits_from`      |not set for the `All-Project` project|
+|`inherits_from`      |not set for the `All-Projects` project|
 The parent project from which permissions are inherited as a
 link:rest-api-projects.html#project-info[ProjectInfo] entity.
 |`local`              ||
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 7c478ae..da15775 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -77,8 +77,8 @@
 link:user-search.html#_search_operators[query string] must be provided
 by the `q` parameter. The `n` parameter can be used to limit the
 returned results. The `no-limit` parameter can be used remove the default
-limit on queries and return all results. This might not be supported by
-all index backends.
+limit on queries and return all results (does not apply to anonymous requests).
+This might not be supported by all index backends.
 
 As result a list of link:#change-info[ChangeInfo] entries is returned.
 The change output is sorted by the last update time, most recently
@@ -794,8 +794,8 @@
           }
         ]
         "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be submitted",
+          "-1": "I would prefer this is not submitted as is",
           " 0": "No score",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
@@ -1925,8 +1925,8 @@
             }
           ],
           "values": {
-            "-2": "This shall not be merged",
-            "-1": "I would prefer this is not merged as is",
+            "-2": "This shall not be submitted",
+            "-1": "I would prefer this is not submitted as is",
             " 0": "No score",
             "+1": "Looks good to me, but someone else must approve",
             "+2": "Looks good to me, approved"
@@ -2022,8 +2022,8 @@
             }
           ],
           "values": {
-            "-2": "This shall not be merged",
-            "-1": "I would prefer this is not merged as is",
+            "-2": "This shall not be submitted",
+            "-1": "I would prefer this is not submitted as is",
             " 0": "No score",
             "+1": "Looks good to me, but someone else must approve",
             "+2": "Looks good to me, approved"
@@ -3973,8 +3973,8 @@
           }
         ]
         "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be submitted",
+          "-1": "I would prefer this is not submitted as is",
           " 0": "No score",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
@@ -5173,8 +5173,8 @@
   }
 ----
 
-[[get-ported-comments]]
-=== Get Ported Comments
+[[list-ported-comments]]
+=== List Ported Comments
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_comments'
 --
@@ -5257,24 +5257,24 @@
   }
 ----
 
-[[get-ported-drafts]]
-=== Get Ported Drafts
+[[list-ported-drafts]]
+=== List Ported Drafts
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_drafts'
 --
 
 Ports draft comments of other revisions to the requested revision.
 
-This endpoint behaves similarly to the link:#get-ported-comments[Get Ported Comments] endpoint.
+This endpoint behaves similarly to the link:#list-ported-comments[List Ported Comments] endpoint.
 With this endpoint, only draft comments of the calling user are ported, though. If a draft comment
 is a reply to a published comment, only the ported draft comment is returned.
 
 Depending on the filtering rules, it's possible that this endpoint returns a draft comment which is
 a reply to a comment thread which is not returned by the
-link:#get-ported-comments[Get Ported Comments] endpoint. That's intended behavior. Callers must be
+link:#list-ported-comments[List Ported Comments] endpoint. That's intended behavior. Callers must be
 able to handle this situation. The same holds for drafts which are a reply to a robot comment.
 
-Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
+Different than the link:#list-ported-comments[List Ported Comments] endpoint, the `author` of the
 returned comments is not filled for this endpoint as only comments of the calling user are returned.
 
 This endpoint requires authentication.
@@ -6641,7 +6641,8 @@
 by one of the following REST endpoints: link:#create-change[Create
 Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
 For Change], link:#cherry-pick[Cherry Pick Revision],
-link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit]
+link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
+link:#rebase-change[Rebase Change]
 |==================================
 
 [[change-input]]
@@ -7745,30 +7746,38 @@
 The `RevertInput` entity contains information for reverting a change.
 
 [options="header",cols="1,^1,5"]
-|=============================
-|Field Name      ||Description
-|`message`       |optional|
+|=================================
+|Field Name          ||Description
+|`message`           |optional|
 Message to be added as review comment to the change when reverting the
 change.
-|`notify`        |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 for reverting the change. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`|optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the revert as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`topic`         |optional|
+|`topic`             |optional|
 Name of the topic for the revert change. If not set, the default for Revert
 endpoint is the topic of the change being reverted, and the default for the
 RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 Topic can't contain quotation marks.
-|`work_in_progress` |optional|
+|`work_in_progress`  |optional|
 When present, change is marked as Work In Progress. The `notify` input is
 used if it's present, otherwise it will be overridden to `OWNER`. +
 If not set, the default is false.
-|=============================
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
+|=================================
 
 [[revert-submission-info]]
 === RevertSubmissionInfo
@@ -8242,6 +8251,13 @@
 `branch:refs/heads/foo and label:verified=+1`.
 |`fulfilled`||
 True if the submit requirement is fulfilled for the change.
+|`status`||
+A string containing the status of evaluating the expression which can be one
+of the following: +
+  * `PASS` - expression was evaluated and result is true. +
+  * `FAIL` - expression was evaluated and result is false. +
+  * `ERROR` - an error occurred while evaluating the expression. +
+  * `NOT_EVALUATED` - expression was not evaluated.
 |`passing_atoms`|optional|
 A list of passing atoms as strings. For the above expression,
 `passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
@@ -8306,16 +8322,17 @@
 submit requirement did not define an applicability expression.
 Note that fields `expression`, `passing_atoms` and `failing_atoms` are always
 omitted for the `applicability_expression_result`.
-|`submittability_expression_result`|optional|
+|`submittability_expression_result`||
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the submittability expression. +
-If the submit requirement does not apply, the expression is not evaluated and
-the field is not set.
+If the submit requirement does not apply, the `status` field of the result
+will be set to `NOT_EVALUATED`.
 |`override_expression_result`|optional|
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the override expression. +
-Not set if the submit requirement did not define an override expression or
-if it does not apply.
+Not set if the submit requirement did not define an override expression. If the
+submit requirement does not apply, the `status` field of the result will be set
+to `NOT_EVALUATED`.
 |===========================
 
 [[submitted-together-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 86d7f58..505def0 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1380,7 +1380,14 @@
   POST /config/server/index.changes HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
-  {changes: ["foo~101", "bar~202"]}
+  {
+    "changes": [
+      "foo~101",
+      "bar~202",
+      "303"
+    ],
+    "delete_missing": "true"
+  }
 ----
 
 .Response
@@ -1389,6 +1396,9 @@
   Content-Disposition: attachment
 ----
 
+When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
+will be deleted in the index.
+
 
 [[ids]]
 == IDs
@@ -1876,6 +1886,10 @@
 |Field Name         ||Description
 |`changes`   ||
 List of link:rest-api-changes.html#change-id[change-ids]
+|`delete_missing`  |optional|
+Delete changes which are missing in NoteDb from the index. This can be used
+to get rid of stale index entries. Possible values are `true` and `false`.
+By default set to `false`.
 |================================
 
 [[jvm-summary-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 7fb5c65..6fa584ac 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3072,16 +3072,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     }
   ]
@@ -3119,16 +3117,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     },
     {
@@ -3137,14 +3133,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_any_score": true,
+      "copy_condition": "is:ANY",
       "allow_post_submit": true
     }
   ]
@@ -3182,16 +3178,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true
   }
 ----
@@ -3219,8 +3213,8 @@
     "commit_message": "Create Foo Label",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     }
@@ -3243,14 +3237,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_all_scores_if_no_change": true,
+    "copy_condition": "changekind:NO_CHANGE",
     "allow_post_submit": true
   }
 ----
@@ -3295,16 +3289,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true,
     "ignore_self_approval": true
   }
@@ -3377,8 +3369,8 @@
         "name": "Foo-Review",
         "values": {
           " 0": "No score",
-          "-1": "I would prefer this is not merged as is",
-          "-2": "This shall not be merged",
+          "-1": "I would prefer this is not submitted as is",
+          "-2": "This shall not be submitted",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
       }
@@ -3388,7 +3380,7 @@
         "function": "MaxWithBlock"
       },
       "Baz-Review": {
-        "copy_min_score": true
+        "copy_condition": "is:MIN"
       }
     }
   }
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 512f784..0a7a77c 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -47,6 +47,9 @@
   an unresolved comment.
 * If another user removed a user's vote, the user with the deleted vote will be
   added to the attention set.
+* If a vote becomes outdated by uploading a new patch set (vote is not sticky),
+  the user whose vote has been removed is added to the attention set, as they
+  need to re-review the change and vote newly.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 * The rules for service accounts are different, see link:#bots[Bots].
 * Users are not added by automatic rules when the change is work in progress.
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index bae083b..f716cb0 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -712,17 +712,20 @@
 `-is:starred` is the exact opposite of `is:starred` and will
 therefore return changes that are *not* starred by the current user.
 
-The operator `NOT` (in all caps) is a synonym.
+The operator `NOT` (in all caps) or `not` (all lower case) is a
+synonym.
 
 === AND
-The boolean operator `AND` (in all caps) can be used to join two
-other operators together.  This results in a restriction of the
-results, returning only changes that match both operators.
+The boolean operator `AND` (in all caps) or `and` (all lower case)
+can be used to join two other operators together.  This results in
+a restriction of the results, returning only changes that match both
+operators.
 
 === OR
-The boolean operator `OR` (in all caps) can be used to find changes
-that match either operator.  This increases the number of results
-that are returned, as more changes are considered.
+The boolean operator `OR` (in all caps) or `or` (all lower case)
+can be used to find changes that match either operator. This
+increases the number of results that are returned, as more changes
+are considered.
 
 
 [[labels]]
diff --git a/README.md b/README.md
index 8a4379b..4df9271 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@
 
 On Debian/Ubuntu run:
 
-        apt-get update & apt-get install gerrit=<version>-<release>
+        apt-get update && apt-get install gerrit=<version>-<release>
 
 _NOTE: release is a counter that starts with 1 and indicates the number of packages that have
 been released with the same version of the software._
diff --git a/WORKSPACE b/WORKSPACE
index 51068e0..fe6b94e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -34,6 +34,15 @@
 load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
 
 http_archive(
+    name = "platforms",
+    sha256 = "379113459b0feaf6bfbb584a91874c065078aa673222846ac765f86661c27407",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
+        "https://github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
+    ],
+)
+
+http_archive(
     name = "rbe_jdk11",
     sha256 = "5939e2a4e56d1fc53b6c44c6db97ee068c9f4bd18e86c762f6ab8b4fff5e294b",
     strip_prefix = "rbe_autoconfig-3.0.0",
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 549946a..23641e3 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -22,6 +22,7 @@
     srcs = [":query"],
     visibility = [
         "//java/com/google/gerrit/index:__subpackages__",
+        "//java/com/google/gerrit/server:__subpackages__",
         "//javatests/com/google/gerrit:__subpackages__",
         "//javatests/com/google/gerrit/index:__pkg__",
         "//plugins:__pkg__",
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index c8587df..7f868e2 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -142,9 +142,9 @@
   | EXACT_PHRASE
   ;
 
-AND: 'AND' ;
-OR:  'OR'  ;
-NOT: 'NOT' ;
+AND: 'AND' | 'and';
+OR:  'OR' | 'or'  ;
+NOT: 'NOT' | 'not' ;
 
 COLON: ':' ;
 
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
index 5b892aa..594903a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
index f15ddae..5221d95 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/_PROJECT",
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
index 467661b..75e895e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects",
     "entries": "PROJECTS_ENTRIES"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
index 5459f11..487cf02 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
index 70e79ca..8b8a163 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index c141bb8..756313a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT",
     "parent": "PARENT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
index 5720f53..8bec9de 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/delete-project~delete"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/delete-project~delete"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
index 86a3c28..4f6a104 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
index e4e2643..e77d83b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
index f6350be..528ef3e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
index d387a3e..d8ebf7f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
@@ -24,7 +24,6 @@
 
 class AbandonChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
   private var createChange: Option[CreateChange] = Some(new CreateChange(projectName))
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
index ae4fa80..692d576 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
@@ -22,7 +22,6 @@
 
 class CheckNewProjectReplica1 extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   private lazy val replicationDuration = replicationDelay + SecondsPerWeightUnit
 
@@ -30,7 +29,6 @@
 
   override def replaceOverride(in: String): String = {
     var next = replaceProperty("http_port1", 8081, in)
-    next = replaceKeyWith("_project", projectName, next)
     super.replaceOverride(next)
   }
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index c283861..d1d9c88 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -22,12 +22,8 @@
 
 class CloneUsingBothProtocols extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private val duration = 2 * numberOfUsers
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .feed(data)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
index 9fef2cf..a7bda3c 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
@@ -22,7 +22,6 @@
 
 class FlushProjectsCache extends CacheFlushSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   override def relativeRuntimeWeight = 2
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index c199dd9..580ae81 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -23,6 +23,7 @@
 class GerritSimulation extends Simulation {
   implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
 
+  private val defaultHostname: String = "localhost"
   protected val numberKey: String = "number"
 
   private val packageName = getClass.getPackage.getName
@@ -42,7 +43,7 @@
   protected val SecondsPerWeightUnit = 2
   val maxExecutionTime: Int = (SecondsPerWeightUnit * relativeRuntimeWeight * powerFactor).toInt
   private var cumulativeWaitTime = 0
-
+  protected var projectName: String = className
   /**
    * How long a scenario step should wait before starting to execute.
    * This is also registering that step's resulting wait time, so that time
@@ -73,16 +74,23 @@
       replaceProperty("parent", "All-Projects", parent.toString)
     case ("project", project) =>
       var precedes = replaceKeyWith("_project", className, project.toString)
-      precedes = replaceOverride(precedes)
+      precedes = replaceProperty("project", getFullProjectName(projectName), precedes)
       replaceProperty("project", precedes)
     case ("url", url) =>
       var in = replaceOverride(url.toString)
-      in = replaceProperty("hostname", "localhost", in)
+      in = replaceProperty("replica_hostname", getProperty("hostname", defaultHostname), in)
+      in = replaceProperty("hostname", defaultHostname, in)
       in = replaceProperty("http_port", 8080, in)
       in = replaceProperty("http_scheme", "http", in)
+      in = replaceProperty("username", "admin", in)
+      in = replaceProperty("context_path", "", in)
       replaceProperty("ssh_port", 29418, in)
   }
 
+  protected def getFullProjectName(projectName: String): String = {
+    getProperty("project_prefix", "") + projectName
+  }
+
   private def replaceProperty(term: String, in: String): String = {
     replaceProperty(term, term, in)
   }
@@ -100,7 +108,7 @@
     val property = packageName + "." + term
     var value = default
     default match {
-      case _: String | _: Double =>
+      case _: String | _: Double | _: Boolean =>
         val propertyValue = Option(System.getProperty(property))
         if (propertyValue.nonEmpty) {
           value = propertyValue.get
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index e2f13a4..5d8dd6f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -15,6 +15,7 @@
 package com.google.gerrit.scenarios
 
 import java.io.{File, IOException}
+import java.net.URLEncoder
 
 import com.github.barbasa.gatling.git.GitRequestSession
 import com.github.barbasa.gatling.git.protocol.GitProtocol
@@ -29,6 +30,14 @@
   protected val gitRequest = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
   protected val gitProtocol: GitProtocol = GitProtocol()
 
+  override def replaceOverride(in: String): String = {
+    var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+    val authenticated = getProperty("authenticated", false).toBoolean
+    val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+    next = replaceKeyWith("context_path", value, next)
+    super.replaceOverride(next)
+  }
+
   after {
     Thread.sleep(5000)
     val path = conf.tmpBasePath
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
index d6cb937..7d7bed7 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.scenarios
 
+import java.net.URLEncoder
+
 class ProjectSimulation extends GerritSimulation {
-  protected var projectName: String = "defaultTestProject"
+  projectName = "defaultTestProject"
 
   override def replaceOverride(in: String): String = {
-    replaceProperty("project", projectName, in)
+    replaceProperty("project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
   }
 }
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 0bd9e4a..c049f09 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -22,13 +22,9 @@
 
 class ReplayRecordsFromFeeder extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
 
   override def relativeRuntimeWeight = 30
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .repeat(10) {
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
index 81096b0..756a239 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
@@ -24,7 +24,6 @@
 
 class RestoreChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
index 20be28a..49f0c4b 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
@@ -23,7 +23,6 @@
 
 class SubmitChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
   private var createChange = new CreateChange(projectName)
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
index 9e1431b..ca618c3 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
@@ -25,7 +25,6 @@
 class SubmitChangeInBranch extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
   private var changesCopy: mutable.Queue[Int] = mutable.Queue[Int]()
-  private val projectName = className
 
   override def relativeRuntimeWeight = 10
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index ec6c7f1..fd208b2 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -100,8 +100,6 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -128,11 +126,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -181,6 +175,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -315,14 +310,11 @@
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
   @Inject private AbstractChangeNotes.Args changeNotesArgs;
-  @Inject private AccountIndexCollection accountIndexes;
   @Inject private AccountIndexer accountIndexer;
-  @Inject private ChangeIndexCollection changeIndexes;
   @Inject private EventRecorder.Factory eventRecorderFactory;
   @Inject private InProcessProtocol inProcessProtocol;
   @Inject private PluginGuiceEnvironment pluginGuiceEnvironment;
   @Inject private PluginUser.Factory pluginUserFactory;
-  @Inject private ProjectIndexCollection projectIndexes;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private SitePaths sitePaths;
   @Inject private ProjectOperations projectOperations;
@@ -463,7 +455,6 @@
 
     baseConfig.setInt("index", null, "batchThreads", -1);
 
-    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     Module auditModule = createAuditModule();
     Module sshModule = createSshModule();
@@ -976,7 +967,7 @@
             repo,
             "new subject",
             "new file",
-            "new content");
+            "new content " + UUID.randomUUID());
     return result;
   }
 
@@ -1073,87 +1064,6 @@
     };
   }
 
-  protected void disableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (!(i instanceof ReadOnlyChangeIndex)) {
-        changeIndexes.addWriteIndex(new ReadOnlyChangeIndex(i));
-      }
-    }
-  }
-
-  protected void enableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (i instanceof ReadOnlyChangeIndex) {
-        changeIndexes.addWriteIndex(((ReadOnlyChangeIndex) i).unwrap());
-      }
-    }
-  }
-
-  protected AutoCloseable disableChangeIndex() {
-    disableChangeIndexWrites();
-    ChangeIndex maybeDisabledSearchIndex = changeIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledChangeIndex)) {
-      changeIndexes.setSearchIndex(new DisabledChangeIndex(maybeDisabledSearchIndex), false);
-    }
-
-    return () -> {
-      enableChangeIndexWrites();
-      ChangeIndex maybeEnabledSearchIndex = changeIndexes.getSearchIndex();
-      if (maybeEnabledSearchIndex instanceof DisabledChangeIndex) {
-        changeIndexes.setSearchIndex(
-            ((DisabledChangeIndex) maybeEnabledSearchIndex).unwrap(), false);
-      }
-    };
-  }
-
-  protected AutoCloseable disableAccountIndex() {
-    AccountIndex maybeDisabledSearchIndex = accountIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledAccountIndex)) {
-      accountIndexes.setSearchIndex(new DisabledAccountIndex(maybeDisabledSearchIndex), false);
-    }
-
-    return () -> {
-      AccountIndex maybeEnabledSearchIndex = accountIndexes.getSearchIndex();
-      if (maybeEnabledSearchIndex instanceof DisabledAccountIndex) {
-        accountIndexes.setSearchIndex(
-            ((DisabledAccountIndex) maybeEnabledSearchIndex).unwrap(), false);
-      }
-    };
-  }
-
-  protected AutoCloseable disableProjectIndex() {
-    disableProjectIndexWrites();
-    ProjectIndex maybeDisabledSearchIndex = projectIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledProjectIndex)) {
-      projectIndexes.setSearchIndex(new DisabledProjectIndex(maybeDisabledSearchIndex), false);
-    }
-
-    return () -> {
-      enableProjectIndexWrites();
-      ProjectIndex maybeEnabledSearchIndex = projectIndexes.getSearchIndex();
-      if (maybeEnabledSearchIndex instanceof DisabledProjectIndex) {
-        projectIndexes.setSearchIndex(
-            ((DisabledProjectIndex) maybeEnabledSearchIndex).unwrap(), false);
-      }
-    };
-  }
-
-  protected void disableProjectIndexWrites() {
-    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
-      if (!(i instanceof DisabledProjectIndex)) {
-        projectIndexes.addWriteIndex(new DisabledProjectIndex(i));
-      }
-    }
-  }
-
-  protected void enableProjectIndexWrites() {
-    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
-      if (i instanceof DisabledProjectIndex) {
-        projectIndexes.addWriteIndex(((DisabledProjectIndex) i).unwrap());
-      }
-    }
-  }
-
   protected static Gson newGson() {
     return OutputFormat.JSON_COMPACT.newGson();
   }
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 6c9a2b1..da033c1 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -98,6 +98,7 @@
 
   protected static class FakeEmailSenderSubject extends Subject {
     private final FakeEmailSender fakeEmailSender;
+    private String emailTitle;
     private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
@@ -145,6 +146,10 @@
                     ? ((StringEmailHeader) header).getString()
                     : header));
       }
+      EmailHeader titleHeader = message.headers().get("Subject");
+      if (titleHeader instanceof StringEmailHeader) {
+        emailTitle = ((StringEmailHeader) titleHeader).getString();
+      }
 
       return this;
     }
@@ -190,6 +195,15 @@
       return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
     }
 
+    public FakeEmailSenderSubject title(String expectedEmailTitle) {
+      if (!emailTitle.equals(expectedEmailTitle)) {
+        failWithoutActual(
+            fact("Expected email title", expectedEmailTitle),
+            fact("but actual title is", emailTitle));
+      }
+      return this;
+    }
+
     private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
       for (String email : emails) {
         rcpt(type, email);
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index dd2a423..0acf3bc 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
@@ -37,6 +38,7 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
@@ -81,10 +83,12 @@
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
   private final DynamicSet<CommentAddedListener> commentAddedListeners;
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private final DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
   private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
   private final DynamicSet<EditWebLink> editWebLinks;
+  private final DynamicSet<FileWebLink> fileWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -123,10 +127,12 @@
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       DynamicSet<CommentAddedListener> commentAddedListeners,
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
+      DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
       DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
       DynamicSet<EditWebLink> editWebLinks,
+      DynamicSet<FileWebLink> fileWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -160,9 +166,11 @@
     this.refOperationValidationListeners = refOperationValidationListeners;
     this.commentAddedListeners = commentAddedListeners;
     this.refUpdatedListeners = refUpdatedListeners;
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
+    this.fileWebLinks = fileWebLinks;
     this.resolveConflictsWebLinks = resolveConflictsWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
@@ -273,6 +281,10 @@
       return add(refUpdatedListeners, refUpdatedListener);
     }
 
+    public Registration add(GitBatchRefUpdateListener batchRefUpdateListener) {
+      return add(batchRefUpdateListeners, batchRefUpdateListener);
+    }
+
     public Registration add(FileHistoryWebLink fileHistoryWebLink) {
       return add(fileHistoryWebLinks, fileHistoryWebLink);
     }
@@ -289,6 +301,10 @@
       return add(editWebLinks, editWebLink);
     }
 
+    public Registration add(FileWebLink fileWebLink) {
+      return add(fileWebLinks, fileWebLink);
+    }
+
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
diff --git a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
deleted file mode 100644
index f7a0669..0000000
--- a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-
-class ReadOnlyChangeIndex implements ChangeIndex {
-  private final ChangeIndex index;
-
-  ReadOnlyChangeIndex(ChangeIndex index) {
-    this.index = index;
-  }
-
-  ChangeIndex unwrap() {
-    return index;
-  }
-
-  @Override
-  public Schema<ChangeData> getSchema() {
-    return index.getSchema();
-  }
-
-  @Override
-  public void close() {
-    index.close();
-  }
-
-  @Override
-  public void insert(ChangeData obj) {
-    // do nothing
-  }
-
-  @Override
-  public void replace(ChangeData obj) {
-    // do nothing
-  }
-
-  @Override
-  public void delete(Change.Id key) {
-    // do nothing
-  }
-
-  @Override
-  public void deleteAll() {
-    // do nothing
-  }
-
-  @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    return index.getSource(p, opts);
-  }
-
-  @Override
-  public void markReady(boolean ready) {
-    // do nothing
-  }
-}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 580f10f..b91a56a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -147,6 +147,8 @@
 
       String refName = RefNames.fullName(changeCreation.branch());
       ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+      changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
+      inserter.setApprovals(changeCreation.approvals());
 
       try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
         batchUpdate.setRepository(repository, revWalk, objectInserter);
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java
new file mode 100644
index 0000000..cba9b15
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java
@@ -0,0 +1,162 @@
+// 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.testsuite.change;
+
+import com.google.gerrit.acceptance.DisabledAccountIndex;
+import com.google.gerrit.acceptance.DisabledChangeIndex;
+import com.google.gerrit.acceptance.DisabledProjectIndex;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.inject.Inject;
+
+/** Helpers to enable and disable reads/writes to secondary indices during testing. */
+public interface IndexOperations {
+  /**
+   * Disables reads from the secondary index that this instance is scoped to. Reads fail with {@code
+   * UnsupportedOperationException}.
+   */
+  AutoCloseable disableReads();
+
+  /**
+   * Disables writes to the secondary index that this instance is scoped to. Writes fail with {@code
+   * UnsupportedOperationException}.
+   */
+  AutoCloseable disableWrites();
+
+  /** Disables reads from and writes to the secondary index that this instance is scoped to. */
+  default AutoCloseable disableReadsAndWrites() {
+    AutoCloseable reads = disableReads();
+    AutoCloseable writes = disableWrites();
+    return () -> {
+      reads.close();
+      writes.close();
+    };
+  }
+
+  class Change implements IndexOperations {
+    @Inject private ChangeIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      ChangeIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledChangeIndex)) {
+        indices.setSearchIndex(
+            new DisabledChangeIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        ChangeIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledChangeIndex) {
+          indices.setSearchIndex(
+              ((DisabledChangeIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (ChangeIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledChangeIndex)) {
+          indices.addWriteIndex(new DisabledChangeIndex(i));
+        }
+      }
+      return () -> {
+        for (ChangeIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledChangeIndex) {
+            indices.addWriteIndex(((DisabledChangeIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+
+  class Account implements IndexOperations {
+    @Inject private AccountIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      AccountIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledAccountIndex)) {
+        indices.setSearchIndex(
+            new DisabledAccountIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        AccountIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledAccountIndex) {
+          indices.setSearchIndex(
+              ((DisabledAccountIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (AccountIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledAccountIndex)) {
+          indices.addWriteIndex(new DisabledAccountIndex(i));
+        }
+      }
+      return () -> {
+        for (AccountIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledAccountIndex) {
+            indices.addWriteIndex(((DisabledAccountIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+
+  class Project implements IndexOperations {
+    @Inject private ProjectIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      ProjectIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledProjectIndex)) {
+        indices.setSearchIndex(
+            new DisabledProjectIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        ProjectIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledProjectIndex) {
+          indices.setSearchIndex(
+              ((DisabledProjectIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (ProjectIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledProjectIndex)) {
+          indices.addWriteIndex(new DisabledProjectIndex(i));
+        }
+      }
+      return () -> {
+        for (ProjectIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledProjectIndex) {
+            indices.addWriteIndex(((DisabledProjectIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index 5871e17..a064d02 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -34,6 +35,10 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<String> topic();
+
+  public abstract ImmutableMap<String, Short> approvals();
+
   public abstract String commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -50,7 +55,8 @@
         .branch(Constants.R_HEADS + Constants.MASTER)
         .commitMessage("A test change")
         // Which value we choose here doesn't matter. All relevant code paths set the desired value.
-        .mergeStrategy(MergeStrategy.OURS);
+        .mergeStrategy(MergeStrategy.OURS)
+        .approvals(ImmutableMap.of());
   }
 
   @AutoValue.Builder
@@ -66,6 +72,15 @@
     /** The change owner. Must be an existing user account. */
     public abstract Builder owner(Account.Id owner);
 
+    /** The topic to add this change to. */
+    public abstract Builder topic(String topic);
+
+    /**
+     * The approvals to apply to this change. Map of label name to value. All approvals will be
+     * granted by the uploader.
+     */
+    public abstract Builder approvals(ImmutableMap<String, Short> approvals);
+
     /**
      * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
      * If the footer is absent, it will be generated.
diff --git a/java/com/google/gerrit/entities/ChangeSizeBucket.java b/java/com/google/gerrit/entities/ChangeSizeBucket.java
new file mode 100644
index 0000000..4d01ebd
--- /dev/null
+++ b/java/com/google/gerrit/entities/ChangeSizeBucket.java
@@ -0,0 +1,64 @@
+// 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.entities;
+
+/**
+ * A human-readable change size bucket.
+ *
+ * <p>Should be kept in sync with
+ * polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+ */
+public class ChangeSizeBucket {
+
+  /**
+   * The upper bounds for the different size buckets.
+   *
+   * <p>Same as gr-change-list-item.ts::ChangeSize.
+   */
+  private enum BucketThresholds {
+    XS(10),
+    SMALL(50),
+    MEDIUM(250),
+    LARGE(1000);
+
+    public final long delta;
+
+    BucketThresholds(long delta) {
+      this.delta = delta;
+    }
+  }
+
+  /**
+   * Gets the correlative size bucket for the given change delta.
+   *
+   * <p>Same as gr-change-list-item.ts::computeChangeSize().
+   *
+   * @param delta the total number of changed lines (additions+deletions) of the change.
+   * @return a short human-readable size bucket.
+   */
+  public static String getChangeSizeBucket(long delta) {
+    if (delta == 0) {
+      return "NoOp";
+    } else if (delta < BucketThresholds.XS.delta) {
+      return "XS";
+    } else if (delta < BucketThresholds.SMALL.delta) {
+      return "S";
+    } else if (delta < BucketThresholds.MEDIUM.delta) {
+      return "M";
+    } else if (delta < BucketThresholds.LARGE.delta) {
+      return "L";
+    }
+    return "XL";
+  }
+}
diff --git a/java/com/google/gerrit/entities/PatchSetApprovals.java b/java/com/google/gerrit/entities/PatchSetApprovals.java
new file mode 100644
index 0000000..b204115
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSetApprovals.java
@@ -0,0 +1,48 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimaps;
+
+/** All approvals of a change by patch set. */
+@AutoValue
+public abstract class PatchSetApprovals {
+  /**
+   * Returns all approvals by patch set, including copied approvals
+   *
+   * <p>Approvals that have been copied from a previous patch set are returned as part of the
+   * result. These approvals can be identified by looking at {@link PatchSetApproval#copied()}.
+   */
+  public abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> all();
+
+  /**
+   * Returns non-copied approvals by patch set.
+   *
+   * <p>Approvals that have been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyNonCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> !entry.getValue().copied()));
+  }
+
+  public static PatchSetApprovals create(
+      ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsByPatchSet) {
+    return new AutoValue_PatchSetApprovals(approvalsByPatchSet);
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 95ad9f8..4142b42 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -70,7 +70,7 @@
 
   // Name of the rule that created this submit record, formatted as '$pluginName~$ruleName'
   public String ruleName;
-  public Status status;
+  public SubmitRecord.Status status;
   public List<Label> labels;
   public List<LegacySubmitRequirement> requirements;
   public String errorMessage;
@@ -113,7 +113,7 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public Account.Id appliedBy;
 
     /**
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 673665d..523b993 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2021 The Android Open Source Project
+// Copyright (C) 2021 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
+// 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
+// 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.
+// 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.entities;
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index aff0994..c24227d 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -95,10 +95,32 @@
         ImmutableList.of());
   }
 
+  public static SubmitRequirementExpressionResult notEvaluated(SubmitRequirementExpression expr) {
+    return SubmitRequirementExpressionResult.create(
+        expr, Status.NOT_EVALUATED, ImmutableList.of(), ImmutableList.of());
+  }
+
   public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
   }
 
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder expression(SubmitRequirementExpression expression);
+
+    public abstract Builder status(Status status);
+
+    public abstract Builder errorMessage(Optional<String> errorMessage);
+
+    public abstract Builder passingAtoms(ImmutableList<String> passingAtoms);
+
+    public abstract Builder failingAtoms(ImmutableList<String> failingAtoms);
+
+    public abstract SubmitRequirementExpressionResult build();
+  }
+
   public enum Status {
     /** Submit requirement expression is fulfilled for a given change. */
     PASS,
@@ -107,7 +129,10 @@
     FAIL,
 
     /** Submit requirement expression contains invalid syntax and is not parsable. */
-    ERROR
+    ERROR,
+
+    /** Submit requirement expression was not evaluated. */
+    NOT_EVALUATED
   }
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index 148d24a..613e48e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -36,4 +36,6 @@
    * {@link NotifyHandling#OWNER}
    */
   public boolean workInProgress;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
index e58e005..6240bba 100644
--- a/java/com/google/gerrit/extensions/client/ChangeKind.java
+++ b/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -29,5 +29,42 @@
   NO_CODE_CHANGE,
 
   /** Same tree, parent tree, same commit message. */
-  NO_CHANGE
+  NO_CHANGE;
+
+  public boolean matches(ChangeKind changeKind, boolean isMerge) {
+    switch (changeKind) {
+      case REWORK:
+        // REWORK inlcudes all other change kinds, since those are just more trivial cases of a
+        // rework
+        return true;
+      case TRIVIAL_REBASE:
+        return isTrivialRebase();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return isMergeFirstParentUpdate(isMerge);
+      case NO_CHANGE:
+        return this == NO_CHANGE;
+      case NO_CODE_CHANGE:
+        return isNoCodeChange();
+    }
+    throw new IllegalStateException("unexpected change kind: " + changeKind);
+  }
+
+  public boolean isNoCodeChange() {
+    // NO_CHANGE is a more trivial case of NO_CODE_CHANGE and hence matched as well
+    return this == NO_CHANGE || this == NO_CODE_CHANGE;
+  }
+
+  public boolean isTrivialRebase() {
+    // NO_CHANGE is a more trivial case of TRIVIAL_REBASE and hence matched as well
+    return this == NO_CHANGE || this == TRIVIAL_REBASE;
+  }
+
+  public boolean isMergeFirstParentUpdate(boolean isMerge) {
+    if (!isMerge) {
+      return false;
+    }
+
+    // NO_CHANGE is a more trivial case of MERGE_FIRST_PARENT_UPDATE and hence matched as well
+    return this == NO_CHANGE || this == MERGE_FIRST_PARENT_UPDATE;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
index 09c9841..e591963 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
@@ -36,7 +36,7 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public AccountInfo appliedBy;
   }
 
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index e9549c9..038b6f8 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -16,7 +16,10 @@
 
 import java.util.List;
 
-/** Result of evaluating a single submit requirement expression. */
+/**
+ * Result of evaluating a single submit requirement expression. This API entity is populated from
+ * {@link com.google.gerrit.entities.SubmitRequirementExpressionResult}.
+ */
 public class SubmitRequirementExpressionInfo {
 
   /** Submit requirement expression as a String. */
@@ -25,6 +28,9 @@
   /** A boolean indicating if the expression is fulfilled on a change. */
   public boolean fulfilled;
 
+  /** A status indicating if the expression is fulfilled, non-fulfilled or not evaluated. */
+  public Status status;
+
   /**
    * A list of all atoms that are passing, for example query "branch:refs/heads/foo and project:bar"
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
@@ -42,4 +48,22 @@
    * during its evaluation.
    */
   public String errorMessage;
+
+  /**
+   * Values in this enum should match with values in {@link
+   * com.google.gerrit.entities.SubmitRequirementExpressionResult.Status}.
+   */
+  public enum Status {
+    /** Expression was evaluated and the result was true. */
+    PASS,
+
+    /** Expression was evaluated and the result was false. */
+    FAIL,
+
+    /** An error occurred while evaluating the expression. */
+    ERROR,
+
+    /** Expression was not evaluated. */
+    NOT_EVALUATED
+  }
 }
diff --git a/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
new file mode 100644
index 0000000..3d638c8
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import java.util.Set;
+
+/** Notified when one or more references are modified. */
+@ExtensionPoint
+public interface GitBatchRefUpdateListener {
+  interface Event extends ProjectEvent {
+    Set<UpdatedRef> getUpdatedRefs();
+
+    Set<String> getRefNames();
+
+    /** The updater, could be null if it's the server. */
+    @Nullable
+    AccountInfo getUpdater();
+  }
+
+  interface UpdatedRef {
+    public String getRefName();
+
+    public String getOldObjectId();
+
+    public String getNewObjectId();
+
+    public boolean isCreate();
+
+    public boolean isDelete();
+
+    public boolean isNonFastForward();
+  }
+
+  void onGitBatchRefUpdate(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
index bf922f8..0fec0f0 100644
--- a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
+++ b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
@@ -21,18 +21,7 @@
 /** Notified when one or more references are modified. */
 @ExtensionPoint
 public interface GitReferenceUpdatedListener {
-  interface Event extends ProjectEvent {
-    String getRefName();
-
-    String getOldObjectId();
-
-    String getNewObjectId();
-
-    boolean isCreate();
-
-    boolean isDelete();
-
-    boolean isNonFastForward();
+  interface Event extends ProjectEvent, GitBatchRefUpdateListener.UpdatedRef {
     /** The updater, could be null if it's the server. */
     @Nullable
     AccountInfo getUpdater();
diff --git a/java/com/google/gerrit/extensions/webui/FileWebLink.java b/java/com/google/gerrit/extensions/webui/FileWebLink.java
index c03d606..dc386b3 100644
--- a/java/com/google/gerrit/extensions/webui/FileWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/FileWebLink.java
@@ -32,8 +32,9 @@
    *
    * @param projectName Name of the project
    * @param revision Name of the revision (e.g. branch or commit ID)
+   * @param hash SHA-1 of the commit
    * @param fileName Name of the file
    * @return WebLinkInfo that links to project in external service, null if there should be no link.
    */
-  WebLinkInfo getFileWebLink(String projectName, String revision, String fileName);
+  WebLinkInfo getFileWebLink(String projectName, String revision, String hash, String fileName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 0e8e28e..74bccbd 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -37,6 +37,34 @@
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
+  @Deprecated
   WebLinkInfo getPatchSetWebLink(
       String projectName, String commit, String commitMessage, String branchName);
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
+   *
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
+   * @param changeKey the changeID for this change
+   * @return WebLinkInfo that links to patch set in external service, null if there should be no
+   *     link.
+   */
+  default WebLinkInfo getPatchSetWebLink(
+      String projectName,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey) {
+    return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
+  }
 }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 9f804c4..0ee5212 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -13,6 +13,7 @@
         "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-factory",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index c3dec61..874f1dc 100644
--- a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -14,28 +14,24 @@
 
 package com.google.gerrit.gpg;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
+@AutoFactory
 public class GerritPushCertificateChecker extends PushCertificateChecker {
-  public interface Factory {
-    GerritPushCertificateChecker create(IdentifiedUser expectedUser);
-  }
-
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @Inject
   GerritPushCertificateChecker(
-      GerritPublicKeyChecker.Factory keyCheckerFactory,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      @Assisted IdentifiedUser expectedUser) {
+      @Provided GerritPublicKeyChecker.Factory keyCheckerFactory,
+      @Provided GitRepositoryManager repoManager,
+      @Provided AllUsersName allUsers,
+      IdentifiedUser expectedUser) {
     super(keyCheckerFactory.create().setExpectedUser(expectedUser));
     this.repoManager = repoManager;
     this.allUsers = allUsers;
diff --git a/java/com/google/gerrit/gpg/GpgModule.java b/java/com/google/gerrit/gpg/GpgModule.java
index 45c1ab5..623b5f0 100644
--- a/java/com/google/gerrit/gpg/GpgModule.java
+++ b/java/com/google/gerrit/gpg/GpgModule.java
@@ -42,7 +42,6 @@
     }
     if (enableSignedPush) {
       install(new SignedPushModule());
-      factory(GerritPushCertificateChecker.Factory.class);
     }
     install(new GpgApiModule(enableSignedPush && configEditGpgKeys));
   }
diff --git a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
index 21a5b6e..abc51c2 100644
--- a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
+++ b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
@@ -48,11 +48,11 @@
   }
 
   private final Provider<IdentifiedUser> user;
-  private final GerritPushCertificateChecker.Factory checkerFactory;
+  private final GerritPushCertificateCheckerFactory checkerFactory;
 
   @Inject
   public SignedPushPreReceiveHook(
-      Provider<IdentifiedUser> user, GerritPushCertificateChecker.Factory checkerFactory) {
+      Provider<IdentifiedUser> user, GerritPushCertificateCheckerFactory checkerFactory) {
     this.user = user;
     this.checkerFactory = checkerFactory;
   }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 652afea..6ae0334 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.GerritPushCertificateChecker;
+import com.google.gerrit.gpg.GerritPushCertificateCheckerFactory;
 import com.google.gerrit.gpg.PushCertificateChecker;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.server.PostGpgKeys;
@@ -44,14 +44,14 @@
   private final Provider<PostGpgKeys> postGpgKeys;
   private final Provider<GpgKeys> gpgKeys;
   private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
-  private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
+  private final GerritPushCertificateCheckerFactory pushCertCheckerFactory;
 
   @Inject
   GpgApiAdapterImpl(
       Provider<PostGpgKeys> postGpgKeys,
       Provider<GpgKeys> gpgKeys,
       GpgKeyApiImpl.Factory gpgKeyApiFactory,
-      GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
+      GerritPushCertificateCheckerFactory pushCertCheckerFactory) {
     this.postGpgKeys = postGpgKeys;
     this.gpgKeys = gpgKeys;
     this.gpgKeyApiFactory = gpgKeyApiFactory;
diff --git a/java/com/google/gerrit/httpd/HttpdModule.java b/java/com/google/gerrit/httpd/HttpdModule.java
index 1f1ec2f..41cef7a 100644
--- a/java/com/google/gerrit/httpd/HttpdModule.java
+++ b/java/com/google/gerrit/httpd/HttpdModule.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 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.httpd;
 
 import com.google.gerrit.extensions.annotations.Exports;
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 1535c87..0073ec2 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -210,7 +210,7 @@
           buf.append("\nResolve above errors before continuing.");
           buf.append("\nComplete stack trace follows:");
         }
-        logger.atSevere().withCause(first.getCause()).log(buf.toString());
+        logger.atSevere().withCause(first.getCause()).log("%s", buf);
         throw new CreationException(Collections.singleton(first));
       }
 
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 460ad60..8e8a9d2 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -94,7 +94,8 @@
           "/elements/*",
           "/fonts/*",
           "/scripts/*",
-          "/styles/*");
+          "/styles/*",
+          "/workers/*");
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index b7dd2f4..f38653d 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -361,9 +361,9 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       String requestUri = requestUri(req);
 
-      try (PerThreadCache ignored = PerThreadCache.create()) {
+      try (PerThreadCache ignored = PerThreadCache.create(req)) {
         List<IdString> path = splitPath(req);
-        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri, path);
+        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         // It's important that the PerformanceLogContext is closed before the response is sent to
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 91c3f70..4235821 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -36,7 +36,6 @@
 
   public static class Builder<T> {
     private final List<FieldDef<T, ?>> fields = new ArrayList<>();
-    private boolean useLegacyNumericFields;
 
     public Builder<T> add(Schema<T> schema) {
       this.fields.addAll(schema.getFields().values());
@@ -55,13 +54,8 @@
       return this;
     }
 
-    public Builder<T> legacyNumericFields(boolean useLegacyNumericFields) {
-      this.useLegacyNumericFields = useLegacyNumericFields;
-      return this;
-    }
-
     public Schema<T> build() {
-      return new Schema<>(useLegacyNumericFields, ImmutableList.copyOf(fields));
+      return new Schema<>(ImmutableList.copyOf(fields));
     }
   }
 
@@ -90,15 +84,14 @@
 
   private final ImmutableMap<String, FieldDef<T, ?>> fields;
   private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
-  private final boolean useLegacyNumericFields;
 
   private int version;
 
-  public Schema(boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
-    this(0, useLegacyNumericFields, fields);
+  public Schema(Iterable<FieldDef<T, ?>> fields) {
+    this(0, fields);
   }
 
-  public Schema(int version, boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
+  public Schema(int version, Iterable<FieldDef<T, ?>> fields) {
     this.version = version;
     ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
     ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
@@ -110,17 +103,12 @@
     }
     this.fields = b.build();
     this.storedFields = sb.build();
-    this.useLegacyNumericFields = useLegacyNumericFields;
   }
 
   public final int getVersion() {
     return version;
   }
 
-  public final boolean useLegacyNumericFields() {
-    return useLegacyNumericFields;
-  }
-
   /**
    * Get all fields in this schema.
    *
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 9599d6a..96fe4fc 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -67,30 +67,23 @@
   }
 
   public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+    return new Schema<>(ImmutableList.copyOf(fields));
   }
 
-  public static <V> Schema<V> schema(Schema<V> schema, boolean useLegacyNumericFields) {
-    return new Schema<>(
-        useLegacyNumericFields,
-        new ImmutableList.Builder<FieldDef<V, ?>>().addAll(schema.getFields().values()).build());
+  @SafeVarargs
+  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
+    return schema(ImmutableList.copyOf(fields));
   }
 
   @SafeVarargs
   public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
     return new Schema<>(
-        true,
         new ImmutableList.Builder<FieldDef<V, ?>>()
             .addAll(schema.getFields().values())
             .addAll(ImmutableList.copyOf(moreFields))
             .build());
   }
 
-  @SafeVarargs
-  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
-  }
-
   public static Set<String> getPersonParts(PersonIdent person) {
     if (person == null) {
       return ImmutableSet.of();
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index 518d153..262ed57 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -18,9 +18,9 @@
   /** Returns an estimate of the number of results from {@link #read()}. */
   int getCardinality();
 
-  /** Returns read from the database and return the results. */
+  /** Returns read from the index and return the results. */
   ResultSet<T> read();
 
-  /** Returns read from the database and return the raw results. */
+  /** Returns read from the index and return the raw results. */
   ResultSet<FieldBundle> readRaw();
 }
diff --git a/java/com/google/gerrit/index/query/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
index ffa7ce4..987c7d3 100644
--- a/java/com/google/gerrit/index/query/QueryBuilder.java
+++ b/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -31,6 +31,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.antlr.runtime.CharStream;
 import org.antlr.runtime.CommonToken;
 import org.antlr.runtime.tree.CommonTree;
@@ -364,7 +366,7 @@
    * @throws QueryParseException the parser does not recognize this value.
    */
   protected Predicate<T> defaultField(String value) throws QueryParseException {
-    throw error("Unsupported query:" + value);
+    throw error("Unsupported query: " + value);
   }
 
   private List<Predicate<T>> children(Tree r) throws QueryParseException, IllegalArgumentException {
@@ -416,8 +418,13 @@
       } catch (RuntimeException | IllegalAccessException e) {
         throw error("Error in operator " + name + ":" + value, e);
       } catch (InvocationTargetException e) {
-        if (e.getCause() instanceof QueryParseException) {
-          throw (QueryParseException) e.getCause();
+        Optional<QueryParseException> queryParseException =
+            Throwables.getCausalChain(e).stream()
+                .filter(QueryParseException.class::isInstance)
+                .map(QueryParseException.class::cast)
+                .findAny();
+        if (queryParseException.isPresent()) {
+          throw queryParseException.get();
         }
         throw error("Error in operator " + name + ":" + value, e.getCause());
       }
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index ceec55c..a190ebf 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -301,7 +301,7 @@
     move(jars, "javax.inject-1.jar", extapi);
     move(jars, "aopalliance-1.0.jar", extapi);
     move(jars, "guice-servlet-", extapi);
-    move(jars, "tomcat-servlet-api-", extapi);
+    move(jars, "servlet-api-", extapi);
 
     ClassLoader parent = ClassLoader.getSystemClassLoader();
     if (!extapi.isEmpty()) {
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 988d6fb..f8256bb 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -66,8 +66,6 @@
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
 import org.apache.lucene.document.IntPoint;
-import org.apache.lucene.document.LegacyIntField;
-import org.apache.lucene.document.LegacyLongField;
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
@@ -88,7 +86,6 @@
 import org.apache.lucene.store.Directory;
 
 /** Basic Lucene index implementation. */
-@SuppressWarnings("deprecation")
 public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -348,13 +345,9 @@
     if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
       for (Object value : values.getValues()) {
         Integer intValue = (Integer) value;
-        if (schema.useLegacyNumericFields()) {
-          doc.add(new LegacyIntField(name, intValue, store));
-        } else {
-          doc.add(new IntPoint(name, intValue));
-          if (store == Store.YES) {
-            doc.add(new StoredField(name, intValue));
-          }
+        doc.add(new IntPoint(name, intValue));
+        if (store == Store.YES) {
+          doc.add(new StoredField(name, intValue));
         }
       }
     } else if (type == FieldType.LONG) {
@@ -383,13 +376,9 @@
   }
 
   private void addLongField(Document doc, String name, Store store, Long longValue) {
-    if (schema.useLegacyNumericFields()) {
-      doc.add(new LegacyLongField(name, longValue, store));
-    } else {
-      doc.add(new LongPoint(name, longValue));
-      if (store == Store.YES) {
-        doc.add(new StoredField(name, longValue));
-      }
+    doc.add(new LongPoint(name, longValue));
+    if (store == Store.YES) {
+      doc.add(new StoredField(name, longValue));
     }
   }
 
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 475dac4..661c0b0 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -15,8 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.ID_STR_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.MERGED_ON_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
@@ -120,12 +119,9 @@
   void add(Document doc, Values<ChangeData> values) {
     // Add separate DocValues fields for those fields needed for sorting.
     FieldDef<ChangeData, ?> f = values.getField();
-    if (f == ChangeField.LEGACY_ID) {
-      int v = (Integer) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ChangeField.LEGACY_ID_STR) {
+    if (f == ChangeField.LEGACY_ID_STR) {
       String v = (String) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
+      doc.add(new NumericDocValuesField(ID_STR_SORT_FIELD, Integer.valueOf(v)));
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index c4a5240..934b27f 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -137,7 +137,7 @@
   @Override
   public void replace(AccountState as) {
     try {
-      replace(idTerm(getSchema().useLegacyNumericFields(), as), toDocument(as)).get();
+      replace(idTerm(getSchema().hasField(ID), as), toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -155,7 +155,7 @@
   @Override
   public void delete(Account.Id key) {
     try {
-      delete(idTerm(getSchema().useLegacyNumericFields(), key)).get();
+      delete(idTerm(getSchema().hasField(ID), key)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -164,15 +164,14 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    queryBuilder.getSchema().useLegacyNumericFields();
     return new LuceneQuerySource(
-        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().useLegacyNumericFields())),
+        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().hasField(ID))),
         queryBuilder.toQuery(p),
         getSort());
   }
 
   private Sort getSort() {
-    String idSortField = getSchema().useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
+    String idSortField = getSchema().hasField(ID) ? ID_SORT_FIELD : ID2_SORT_FIELD;
     return new Sort(
         new SortField(FULL_NAME_SORT_FIELD, SortField.Type.STRING, false),
         new SortField(EMAIL_SORT_FIELD, SortField.Type.STRING, false),
@@ -181,10 +180,10 @@
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    FieldDef<AccountState, ?> idField = getSchema().useLegacyNumericFields() ? ID : ID_STR;
+    FieldDef<AccountState, ?> idField = getSchema().hasField(ID) ? ID : ID_STR;
     Account.Id id =
         Account.id(
-            getSchema().useLegacyNumericFields()
+            getSchema().hasField(ID)
                 ? doc.getField(idField.getName()).numericValue().intValue()
                 : Integer.valueOf(doc.getField(idField.getName()).stringValue()));
     // Use the AccountCache rather than depending on any stored fields in the document (of which
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 9ea9d2e..cf176ee 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
@@ -100,30 +99,39 @@
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
   static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
-  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
-  static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
+  static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
 
-  @FunctionalInterface
-  interface IdTerm {
-    Term get(String name, int id);
+  /*
+    @FunctionalInterface
+    interface IdTerm {
+      Term get(String name, int id);
+    }
+
+    static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
+      return idTerm(idTerm, idField, cd.getId());
+    }
+
+    static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
+      return idTerm.get(idField.getName(), id.get());
+    }
+
+    @FunctionalInterface
+    interface ChangeIdExtractor {
+      Change.Id extract(IndexableField f);
+    }
+  */
+
+  static Term idTerm(ChangeData cd) {
+    return idTerm(cd.getId());
   }
 
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
-    return idTerm(idTerm, idField, cd.getId());
-  }
-
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
-    return idTerm.get(idField.getName(), id.get());
-  }
-
-  @FunctionalInterface
-  interface ChangeIdExtractor {
-    Change.Id extract(IndexableField f);
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.stringTerm(LEGACY_ID_STR.getName(), Integer.toString(id.get()));
   }
 
   private final ListeningExecutorService executor;
@@ -132,12 +140,6 @@
   private final QueryBuilder<ChangeData> queryBuilder;
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
-
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final FieldDef<ChangeData, ?> idField;
-  private final String idSortFieldName;
-  private final IdTerm idTerm;
-  private final ChangeIdExtractor extractor;
   private final ImmutableSet<String> skipFields;
 
   @Inject
@@ -205,20 +207,6 @@
               searcherFactory,
               autoFlush);
     }
-
-    idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
-    idSortFieldName = schema.useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
-    idTerm =
-        (name, id) ->
-            this.schema.useLegacyNumericFields()
-                ? QueryBuilder.intTerm(name, id)
-                : QueryBuilder.stringTerm(name, Integer.toString(id));
-    extractor =
-        (f) ->
-            Change.id(
-                this.schema.useLegacyNumericFields()
-                    ? f.numericValue().intValue()
-                    : Integer.valueOf(f.stringValue()));
   }
 
   @Override
@@ -237,7 +225,7 @@
 
   @Override
   public void replace(ChangeData cd) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, cd);
+    Term id = LuceneChangeIndex.idTerm(cd);
     // toDocument is essentially static and doesn't depend on the specific
     // sub-index, so just pick one.
     Document doc = openIndex.toDocument(cd);
@@ -270,9 +258,9 @@
 
   @Override
   public void delete(Change.Id changeId) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, changeId);
+    Term idTerm = LuceneChangeIndex.idTerm(changeId);
     try {
-      Futures.allAsList(openIndex.delete(id), closedIndex.delete(id)).get();
+      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -309,7 +297,7 @@
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
         new SortField(MERGED_ON_SORT_FIELD, SortField.Type.LONG, true),
-        new SortField(idSortFieldName, SortField.Type.LONG, true));
+        new SortField(ID_STR_SORT_FIELD, SortField.Type.LONG, true));
   }
 
   private class QuerySource implements ChangeDataSource {
@@ -357,7 +345,7 @@
         throw new StorageException("interrupted");
       }
 
-      final Set<String> fields = IndexUtils.changeFields(opts, schema.useLegacyNumericFields());
+      final Set<String> fields = IndexUtils.changeFields(opts);
       return new ChangeDataResults(
           executor.submit(
               new Callable<List<Document>>() {
@@ -378,7 +366,7 @@
     public ResultSet<FieldBundle> readRaw() {
       List<Document> documents;
       try {
-        documents = doRead(IndexUtils.changeFields(opts, schema.useLegacyNumericFields()));
+        documents = doRead(IndexUtils.changeFields(opts));
       } catch (IOException e) {
         throw new StorageException(e);
       }
@@ -457,7 +445,7 @@
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, idField.getName()));
+          result.add(toChangeData(fields(doc, fields), fields, LEGACY_ID_STR.getName()));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -499,9 +487,10 @@
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
 
+      Change.Id id = Change.id(Integer.valueOf(f.stringValue()));
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
     for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index e1b56c6..bd34743 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
@@ -39,36 +40,18 @@
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.BooleanQuery;
-import org.apache.lucene.search.LegacyNumericRangeQuery;
 import org.apache.lucene.search.MatchAllDocsQuery;
 import org.apache.lucene.search.PrefixQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.RegexpQuery;
 import org.apache.lucene.search.TermQuery;
 import org.apache.lucene.util.BytesRefBuilder;
-import org.apache.lucene.util.LegacyNumericUtils;
 
-@SuppressWarnings("deprecation")
 public class QueryBuilder<V> {
-  @FunctionalInterface
-  static interface IntTermQuery {
-    Query get(String name, int value);
-  }
-
-  @FunctionalInterface
-  static interface IntRangeQuery {
-    Query get(String name, int min, int max);
-  }
-
-  @FunctionalInterface
-  static interface LongRangeQuery {
-    Query get(String name, long min, long max);
-  }
-
-  static Term intTerm(String name, int value) {
-    BytesRefBuilder builder = new BytesRefBuilder();
-    LegacyNumericUtils.intToPrefixCoded(value, 0, builder);
-    return new Term(name, builder.get());
+  /** @param name field name qparam i key value */
+  static Term intTerm(String name, int i) {
+    checkState(false, "Lucene index implementation removed legacy numeric type");
+    return null;
   }
 
   static Term stringTerm(String name, String value) {
@@ -84,29 +67,9 @@
   private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final IntTermQuery intTermQuery;
-  private final IntRangeQuery intRangeTermQuery;
-  private final LongRangeQuery longRangeQuery;
-
   public QueryBuilder(Schema<V> schema, Analyzer analyzer) {
     this.schema = schema;
     queryBuilder = new org.apache.lucene.util.QueryBuilder(analyzer);
-    intTermQuery =
-        (name, value) ->
-            this.schema.useLegacyNumericFields()
-                ? new TermQuery(intTerm(name, value))
-                : intPoint(name, value);
-    intRangeTermQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newIntRange(name, min, max, true, true)
-                : IntPoint.newRangeQuery(name, min, max);
-    longRangeQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newLongRange(name, min, max, true, true)
-                : LongPoint.newRangeQuery(name, min, max);
   }
 
   public Query toQuery(Predicate<V> p) throws QueryParseException {
@@ -209,7 +172,7 @@
     } catch (NumberFormatException e) {
       throw new QueryParseException("not an integer: " + p.getValue(), e);
     }
-    return intTermQuery.get(p.getField().getName(), value);
+    return intPoint(p.getField().getName(), value);
   }
 
   private Query intRangeQuery(IndexPredicate<V> p) throws QueryParseException {
@@ -220,9 +183,9 @@
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
-        return intTermQuery.get(name, minimum);
+        return intPoint(name, minimum);
       }
-      return intRangeTermQuery.get(name, minimum, maximum);
+      return IntPoint.newRangeQuery(name, minimum, maximum);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
@@ -230,7 +193,7 @@
   private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
-      return longRangeQuery.get(
+      return LongPoint.newRangeQuery(
           r.getField().getName(),
           r.getMinTimestamp().toEpochMilli(),
           r.getMaxTimestamp().toEpochMilli());
@@ -240,7 +203,7 @@
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
     if (r.getMinTimestamp().toEpochMilli() == 0) {
-      return longRangeQuery.get(
+      return LongPoint.newRangeQuery(
           r.getField().getName(), r.getMaxTimestamp().toEpochMilli(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index 9e43a05..f5555b5 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -35,8 +35,6 @@
 
   @Override
   public long getCurrentThreadAllocatedBytes() {
-    // TODO(ms): call getCurrentThreadAllocatedBytes as soon as this is available in the patched
-    // Java version used by bazel
-    return sys.getThreadAllocatedBytes(Thread.currentThread().getId());
+    return sys.getCurrentThreadAllocatedBytes();
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index cbfd714..c56a8d9 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -115,7 +115,7 @@
       if (result != Result.NEW) {
         throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
       }
-      account.setMetaId(id.name()).build();
+      account.setMetaId(id.name());
     }
     return account.build();
   }
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 3edc732..27021bd 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -26,7 +26,8 @@
 
 @Singleton
 public class InitLabels implements InitStep {
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  private static final String KEY_COPY_CONDITION = "copyCondition";
   private static final String KEY_LABEL = "label";
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_VALUE = "value";
@@ -62,7 +63,17 @@
           LABEL_VERIFIED,
           KEY_VALUE,
           Arrays.asList("-1 Fails", "0 No score", "+1 Verified"));
-      cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
+
+      // override the default which is true and rely on the copy condition instead
+      cfg.setBoolean(
+          KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false);
+
+      cfg.setString(
+          KEY_LABEL,
+          LABEL_VERIFIED,
+          KEY_COPY_CONDITION,
+          "changekind:NO_CHANGE OR changekind:NO_CODE_CHANGE");
+
       allProjectsConfig.save("Configure 'Verified' label");
     }
   }
diff --git a/java/com/google/gerrit/pgm/util/BatchGitModule.java b/java/com/google/gerrit/pgm/util/BatchGitModule.java
index d39c2fd..fa585d3 100644
--- a/java/com/google/gerrit/pgm/util/BatchGitModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchGitModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.git.GitModule;
@@ -24,6 +25,7 @@
 public class BatchGitModule extends FactoryModule {
   @Override
   protected void configure() {
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     install(new GitModule());
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 41ed991..d2e4b18 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
@@ -63,11 +64,9 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -150,9 +149,8 @@
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
     bind(IdentifiedUser.class).toProvider(Providers.of(null));
-    bind(ReplacePatchSetSender.Factory.class).toProvider(Providers.of(null));
+    bind(EmailNewPatchSet.Factory.class).toProvider(Providers.of(null));
     bind(CurrentUser.class).to(IdentifiedUser.class);
-    factory(MergeUtil.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7a6187d..5365426 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -104,6 +104,7 @@
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 84afe8c..827c078 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -32,13 +32,12 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * A {@link BatchUpdateOp} that can be used to publish draft comments
@@ -52,15 +51,14 @@
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
-  private final List<LabelVote> labelDelta = new ArrayList<>();
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
   private final ChangeMessagesUtil changeMessagesUtil;
 
+  private ObjectId preUpdateMetaId;
   private List<HumanComment> comments = new ArrayList<>();
   private String mailMessage;
-  private IdentifiedUser user;
 
   public interface Factory {
     PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
@@ -92,7 +90,7 @@
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, UnprocessableEntityException, IOException,
           PatchListNotAvailableException, CommentsRejectedException {
-    user = ctx.getIdentifiedUser();
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
 
     // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
@@ -103,7 +101,7 @@
     //   2. Each ChangeUpdate results in 1 commit in NoteDb
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
-    publishCommentUtil.publish(ctx, changeUpdate, comments, null);
+    publishCommentUtil.publish(ctx, changeUpdate, comments, /* tag= */ null);
     return insertMessage(changeUpdate);
   }
 
@@ -116,25 +114,15 @@
     PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
-      RepoView repoView;
-      try {
-        repoView = ctx.getRepoView();
-      } catch (IOException ex) {
-        throw new StorageException(
-            String.format("Repository %s not found", ctx.getProject().get()), ex);
-      }
       email
           .create(
-              notify,
-              changeNotes,
+              ctx,
               ps,
-              user,
+              preUpdateMetaId,
               mailMessage,
-              ctx.getWhen(),
               comments,
-              null,
-              labelDelta,
-              repoView)
+              /* patchSetComment= */ null,
+              /* labels= */ ImmutableList.of())
           .sendAsync();
     }
     commentAdded.fire(
@@ -159,7 +147,7 @@
     }
     mailMessage =
         changeMessagesUtil.setChangeMessage(
-            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, /* tag= */ null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 33f2ad1..95d891e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -380,18 +380,24 @@
         TraceContext.newTimer(
             "Read star labels", Metadata.builder().noteDbRefName(refName).build())) {
       Ref ref = repo.exactRef(refName);
-      if (ref == null) {
-        return StarRef.MISSING;
-      }
+      return readLabels(repo, ref);
+    }
+  }
 
-      try (ObjectReader reader = repo.newObjectReader()) {
-        ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
-        return StarRef.create(
-            ref,
-            Splitter.on(CharMatcher.whitespace())
-                .omitEmptyStrings()
-                .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-      }
+  public static StarRef readLabels(Repository repo, Ref ref) throws IOException {
+    if (ref == null) {
+      return StarRef.MISSING;
+    }
+    try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                String.format("Read star labels from %s (without ref lookup)", ref.getName()));
+        ObjectReader reader = repo.newObjectReader()) {
+      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
+      return StarRef.create(
+          ref,
+          Splitter.on(CharMatcher.whitespace())
+              .omitEmptyStrings()
+              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
     }
   }
 
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 3a82694..58396f5 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -86,12 +86,19 @@
    * @param commit SHA1 of commit.
    * @param commitMessage the commit message of the commit.
    * @param branchName branch of the commit.
+   * @param changeKey change Identifier for this change
    */
   public ImmutableList<WebLinkInfo> getPatchSetLinks(
-      Project.NameKey project, String commit, String commitMessage, String branchName) {
+      Project.NameKey project,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey) {
     return filterLinks(
         patchSetLinks,
-        webLink -> webLink.getPatchSetWebLink(project.get(), commit, commitMessage, branchName));
+        webLink ->
+            webLink.getPatchSetWebLink(
+                project.get(), commit, commitMessage, branchName, changeKey));
   }
 
   /**
@@ -142,13 +149,15 @@
    * Returns links for files
    *
    * @param project Project name.
-   * @param revision SHA1 of revision.
+   * @param revision Name of the revision (e.g. branch or commit ID)
+   * @param hash SHA1 of revision.
    * @param file File name.
    */
-  public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileLinks(
+      String project, String revision, String hash, String file) {
     return Patch.isMagic(file)
         ? ImmutableList.of()
-        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
+        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, hash, file));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index c7f6496..a4d7608 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -117,7 +117,7 @@
         return true;
       }
     } catch (ResourceConflictException e) {
-      logger.atInfo().log("Account %s already deactivated, continuing...", userName);
+      logger.atInfo().withCause(e).log("Account %s already deactivated, continuing...", userName);
     } catch (Exception e) {
       logger.atSevere().withCause(e).log(
           "Error deactivating account: %s (%s) %s",
diff --git a/java/com/google/gerrit/server/account/AccountTagProvider.java b/java/com/google/gerrit/server/account/AccountTagProvider.java
index ddb1331..e74bde7 100644
--- a/java/com/google/gerrit/server/account/AccountTagProvider.java
+++ b/java/com/google/gerrit/server/account/AccountTagProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 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.account;
 
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 5bd9bea..1587bc5 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,10 +27,16 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,11 +55,57 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Field<String> SYSTEM_FIELD =
+      Field.ofString("system", Metadata.Builder::groupSystem).build();
+
   private final PluginSetContext<GroupBackend> backends;
+  private final Counter1<String> handlesCount;
+  private final Counter1<String> getCount;
+  private final Counter2<String, Integer> suggestCount;
+  private final Counter2<String, Boolean> containsCount;
+  private final Counter2<String, Boolean> containsAnyCount;
+  private final Counter2<String, Integer> intersectionCount;
+  private final Counter2<String, Integer> knownGroupsCount;
 
   @Inject
-  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends, MetricMaker metricMaker) {
     this.backends = backends;
+    this.handlesCount =
+        metricMaker.newCounter(
+            "group/handles_count", new Description("Calls to GroupBackend.handles"), SYSTEM_FIELD);
+    this.getCount =
+        metricMaker.newCounter(
+            "group/get_count", new Description("Calls to GroupBackend.get"), SYSTEM_FIELD);
+    this.suggestCount =
+        metricMaker.newCounter(
+            "group/suggest_count",
+            new Description("Calls to GroupBackend.suggest"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_suggested", (meta, value) -> {}).build());
+    this.containsCount =
+        metricMaker.newCounter(
+            "group/contains_count",
+            new Description("Calls to GroupMemberships.contains"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains", (meta, value) -> {}).build());
+    this.containsAnyCount =
+        metricMaker.newCounter(
+            "group/contains_any_of_count",
+            new Description("Calls to GroupMemberships.containsAnyOf"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains_any_of", (meta, value) -> {}).build());
+    this.intersectionCount =
+        metricMaker.newCounter(
+            "group/intersection_count",
+            new Description("Calls to GroupMemberships.intersection"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_intersection", (meta, value) -> {}).build());
+    this.knownGroupsCount =
+        metricMaker.newCounter(
+            "group/known_groups_count",
+            new Description("Calls to GroupMemberships.getKnownGroups"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_known_groups", (meta, value) -> {}).build());
   }
 
   @Nullable
@@ -70,7 +122,12 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return backend(uuid) != null;
+    GroupBackend b = backend(uuid);
+    if (b == null) {
+      return false;
+    }
+    handlesCount.increment(name(b));
+    return true;
   }
 
   @Override
@@ -83,13 +140,19 @@
       logger.atFine().log("Unknown GroupBackend for UUID: %s", uuid);
       return null;
     }
+    getCount.increment(name(b));
     return b.get(uuid);
   }
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
+    backends.runEach(
+        g -> {
+          Collection<GroupReference> suggestions = g.suggest(name, project);
+          suggestCount.increment(name(g), suggestions.size());
+          groups.addAll(suggestions);
+        });
     return groups;
   }
 
@@ -108,11 +171,11 @@
     }
 
     @Nullable
-    private GroupMembership membership(AccountGroup.UUID uuid) {
+    private Map.Entry<GroupBackend, GroupMembership> membership(AccountGroup.UUID uuid) {
       if (uuid != null) {
         for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
           if (m.getKey().handles(uuid)) {
-            return m.getValue();
+            return m;
           }
         }
       }
@@ -125,51 +188,57 @@
       if (uuid == null) {
         return false;
       }
-      GroupMembership m = membership(uuid);
+      Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
       if (m == null) {
         return false;
       }
-      return m.contains(uuid);
+      boolean contains = m.getValue().contains(uuid);
+      containsCount.increment(name(m.getKey()), contains);
+      return contains;
     }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           continue;
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        GroupMembership m = entry.getKey();
-        Collection<AccountGroup.UUID> ids = entry.getValue();
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackends : lookups.asMap().keySet()) {
+
+        GroupMembership m = groupBackends.getValue();
+        Collection<AccountGroup.UUID> ids = lookups.asMap().get(groupBackends);
         if (ids.size() == 1) {
           if (m.contains(Iterables.getOnlyElement(ids))) {
+            containsAnyCount.increment(name(groupBackends.getKey()), true);
             return true;
           }
         } else if (m.containsAnyOf(ids)) {
+          containsAnyCount.increment(name(groupBackends.getKey()), true);
           return true;
         }
+        // We would have returned if contains was true.
+        containsAnyCount.increment(name(groupBackends.getKey()), false);
       }
       return false;
     }
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           logger.atFine().log("Unknown GroupMembership for UUID: %s", uuid);
           continue;
@@ -177,9 +246,11 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        groups.addAll(entry.getKey().intersection(entry.getValue()));
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackend : lookups.asMap().keySet()) {
+        Set<AccountGroup.UUID> intersection =
+            groupBackend.getValue().intersection(lookups.asMap().get(groupBackend));
+        intersectionCount.increment(name(groupBackend.getKey()), intersection.size());
+        groups.addAll(intersection);
       }
       return groups;
     }
@@ -187,8 +258,10 @@
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (GroupMembership m : memberships.values()) {
-        groups.addAll(m.getKnownGroups());
+      for (Map.Entry<GroupBackend, GroupMembership> entry : memberships.entrySet()) {
+        Set<AccountGroup.UUID> knownGroups = entry.getValue().getKnownGroups();
+        knownGroupsCount.increment(name(entry.getKey()), knownGroups.size());
+        groups.addAll(knownGroups);
       }
       return groups;
     }
@@ -204,6 +277,13 @@
     return false;
   }
 
+  private static String name(GroupBackend backend) {
+    if (backend == null) {
+      return "none";
+    }
+    return backend.getClass().getSimpleName();
+  }
+
   public static class ConfigCheck implements StartupCheck {
     private final Config cfg;
     private final UniversalGroupBackend universalGroupBackend;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index a5fb733..0a51171 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -106,8 +106,10 @@
   static final String PASSWORD_KEY = "password";
 
   /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   * Scheme used to label accounts created, when using the LDAP-based authentication types {@link
+   * AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link AuthType#HTTP_LDAP}, and {@link
+   * AuthType#LDAP_BIND}. The external ID stores the username. Accounts with such an external ID
+   * will be authenticated against the configured LDAP identity provider.
    *
    * <p>The name {@code gerrit:} was a very poor choice.
    *
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
similarity index 72%
rename from java/com/google/gerrit/server/approval/ApprovalInference.java
rename to java/com/google/gerrit/server/approval/ApprovalCopier.java
index fba2fcb..ac35b5a 100644
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -28,6 +30,7 @@
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
@@ -50,7 +53,7 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Collection;
+import java.io.IOException;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
@@ -58,18 +61,59 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
- * Computes approvals for a given patch set by looking at approvals applied to the given patch set
- * and by additionally inferring approvals from the patch set's parents. The latter is done by
- * asserting a change's kind and checking the project config for allowed forward-inference.
+ * Computes copied approvals for a given patch set.
  *
- * <p>The result of a copy may either be stored, as when stamping approvals in the database at
- * submit time, or refreshed on demand, as when reading approvals from the NoteDb. TODO(ghareeb):
- * migrate to new diff cache
+ * <p>Approvals are copied if:
+ *
+ * <ul>
+ *   <li>the approval on the previous patch set matches the copy condition of its label
+ *   <li>the approval is not overridden by a current approval on the patch set
+ * </ul>
+ *
+ * <p>Callers should store the copied approvals in NoteDb when a new patch set is created.
  */
 @Singleton
-class ApprovalInference {
+@VisibleForTesting
+public class ApprovalCopier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @AutoValue
+  public abstract static class Result {
+    /**
+     * Approvals that have been copied from the previous patch set.
+     *
+     * <p>An approval is copied if:
+     *
+     * <ul>
+     *   <li>the approval on the previous patch set matches the copy condition of its label
+     *   <li>the approval is not overridden by a current approval on the patch set
+     * </ul>
+     */
+    public abstract ImmutableSet<PatchSetApproval> copiedApprovals();
+
+    /**
+     * Approvals on the previous patch set that have not been copied to the patch set.
+     *
+     * <p>These approvals didn't match the copy condition of their labels and hence haven't been
+     * copied.
+     *
+     * <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
+     * sets that were outdated before are not included.
+     */
+    public abstract ImmutableSet<PatchSetApproval> outdatedApprovals();
+
+    static Result empty() {
+      return create(
+          /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    }
+
+    static Result create(
+        ImmutableSet<PatchSetApproval> copiedApprovals,
+        ImmutableSet<PatchSetApproval> outdatedApprovals) {
+      return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
+    }
+  }
+
   private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
@@ -79,7 +123,7 @@
   private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
-  ApprovalInference(
+  ApprovalCopier(
       DiffOperations diffOperations,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
@@ -97,11 +141,17 @@
   }
 
   /**
-   * Returns all approvals that apply to the given patch set. Honors copied approvals from previous
-   * patch-set.
+   * Returns all copied approvals that apply to the given patch set.
+   *
+   * <p>Approvals are copied if:
+   *
+   * <ul>
+   *   <li>the approval on the previous patch set matches the copy condition of its label
+   *   <li>the approval is not overridden by a current approval on the patch set
+   * </ul>
    */
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
+  @VisibleForTesting
+  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
     ProjectState project;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -114,17 +164,16 @@
           projectCache
               .get(notes.getProjectName())
               .orElseThrow(illegalState(notes.getProjectName()));
-      Collection<PatchSetApproval> approvals =
-          getForPatchSetWithoutNormalization(notes, project, ps, rw, repoConfig);
-      return labelNormalizer.normalize(notes, approvals).getNormalized();
+      return computeForPatchSet(project.getLabelTypes(), notes, ps, rw, repoConfig);
     }
   }
 
   private boolean canCopyBasedOnBooleanLabelConfigs(
-      ProjectState project,
+      Project.NameKey projectName,
       PatchSetApproval psa,
       PatchSet.Id psId,
       ChangeKind kind,
+      boolean isMerge,
       LabelType type,
       @Nullable Map<String, ModifiedFile> baseVsCurrentDiff,
       @Nullable Map<String, ModifiedFile> baseVsPriorDiff,
@@ -141,7 +190,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
       logger.atFine().log(
@@ -152,7 +201,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.isCopyAnyScore()) {
       logger.atFine().log(
@@ -163,7 +212,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.getCopyValues().contains(psa.value())) {
       logger.atFine().log(
@@ -175,7 +224,7 @@
           psa.key().patchSetId().changeId().get(),
           psId.get(),
           psa.value(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
         && listOfFilesUnchangedPredicate.match(
@@ -191,7 +240,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     }
     switch (kind) {
@@ -207,7 +256,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -223,7 +272,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -239,7 +288,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -255,7 +304,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         if (type.isCopyAllScoresOnTrivialRebase()) {
@@ -269,10 +318,10 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
+        if (isMerge && type.isCopyAllScoresOnMergeFirstParentUpdate()) {
           logger.atFine().log(
               "approval %d on label %s of patch set %d of change %d can be copied"
                   + " to patch set %d because change kind is %s and the label has set"
@@ -283,7 +332,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         if (type.isCopyAllScoresIfNoCodeChange()) {
@@ -297,7 +346,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -317,13 +366,15 @@
       PatchSet patchSet,
       LabelType type,
       ChangeKind changeKind,
+      boolean isMerge,
       RevWalk revWalk,
       Config repoConfig) {
     if (!type.getCopyCondition().isPresent()) {
       return false;
     }
     ApprovalContext ctx =
-        ApprovalContext.create(changeNotes, psa, patchSet, changeKind, revWalk, repoConfig);
+        ApprovalContext.create(
+            changeNotes, psa, patchSet, changeKind, isMerge, revWalk, repoConfig);
     try {
       // Use a request context to run checks as an internal user with expanded visibility. This is
       // so that the output of the copy condition does not depend on who is running the current
@@ -339,44 +390,39 @@
     }
   }
 
-  private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
-      ChangeNotes notes, ProjectState project, PatchSet patchSet, RevWalk rw, Config repoConfig) {
-    checkState(
-        project.getNameKey().equals(notes.getProjectName()),
-        "project must match %s, %s",
-        project.getNameKey(),
-        notes.getProjectName());
-
+  private Result computeForPatchSet(
+      LabelTypes labelTypes, ChangeNotes notes, PatchSet patchSet, RevWalk rw, Config repoConfig) {
+    Project.NameKey projectName = notes.getProjectName();
     PatchSet.Id psId = patchSet.id();
-    // Add approvals on the given patch set to the result
-    Table<String, Account.Id, PatchSetApproval> resultByUser = HashBasedTable.create();
-    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
-        notes.load().getApprovals().get(patchSet.id());
-    nonCopiedApprovalsForGivenPatchSet.forEach(
-        psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
 
     // Bail out immediately if this is the first patch set. Return only approvals granted on the
     // given patch set.
     if (psId.get() == 1) {
-      return resultByUser.values();
+      return Result.empty();
     }
     Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
     if (priorPatchSet == null) {
-      return resultByUser.values();
+      return Result.empty();
     }
 
-    ImmutableList<PatchSetApproval> priorApprovalsIncludingCopied =
-        notes.load().getApprovalsWithCopied().get(priorPatchSet.getKey());
+    Table<String, Account.Id, PatchSetApproval> currentApprovalsByUser = HashBasedTable.create();
+    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
+        notes.load().getApprovals().onlyNonCopied().get(patchSet.id());
+    nonCopiedApprovalsForGivenPatchSet.forEach(
+        psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
+
+    Table<String, Account.Id, PatchSetApproval> copiedApprovalsByUser = HashBasedTable.create();
+    ImmutableSet.Builder<PatchSetApproval> outdatedApprovalsBuilder = ImmutableSet.builder();
+
+    ImmutableList<PatchSetApproval> priorApprovals =
+        notes.load().getApprovals().all().get(priorPatchSet.getKey());
 
     // Add labels from the previous patch set to the result in case the label isn't already there
     // and settings as well as change kind allow copying.
     ChangeKind changeKind =
         changeKindCache.getChangeKind(
-            project.getNameKey(),
-            rw,
-            repoConfig,
-            priorPatchSet.getValue().commitId(),
-            patchSet.commitId());
+            projectName, rw, repoConfig, priorPatchSet.getValue().commitId(), patchSet.commitId());
+    boolean isMerge = isMerge(projectName, rw, patchSet);
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         patchSet.id().get(),
@@ -387,21 +433,21 @@
     Map<String, ModifiedFile> baseVsCurrent = null;
     Map<String, ModifiedFile> baseVsPrior = null;
     Map<String, ModifiedFile> priorVsCurrent = null;
-    LabelTypes labelTypes = project.getLabelTypes();
-    for (PatchSetApproval psa : priorApprovalsIncludingCopied) {
-      if (resultByUser.contains(psa.label(), psa.accountId())) {
-        continue;
-      }
+    for (PatchSetApproval psa : priorApprovals) {
       Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
       // Only compute modified files if there is a relevant label, since this is expensive.
       if (baseVsCurrent == null
           && type.isPresent()
           && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
-        baseVsCurrent = listModifiedFiles(project, patchSet, rw, repoConfig);
-        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue(), rw, repoConfig);
+        baseVsCurrent = listModifiedFiles(projectName, patchSet, rw, repoConfig);
+        baseVsPrior = listModifiedFiles(projectName, priorPatchSet.getValue(), rw, repoConfig);
         priorVsCurrent =
             listModifiedFiles(
-                project, priorPatchSet.getValue().commitId(), patchSet.commitId(), rw, repoConfig);
+                projectName,
+                priorPatchSet.getValue().commitId(),
+                patchSet.commitId(),
+                rw,
+                repoConfig);
       }
       if (!type.isPresent()) {
         logger.atFine().log(
@@ -412,25 +458,46 @@
             psa.key().patchSetId().get(),
             psa.key().patchSetId().changeId().get(),
             psId.get(),
-            project.getName());
+            projectName);
+        outdatedApprovalsBuilder.add(psa);
         continue;
       }
       if (!canCopyBasedOnBooleanLabelConfigs(
-              project,
+              projectName,
               psa,
               patchSet.id(),
               changeKind,
+              isMerge,
               type.get(),
               baseVsCurrent,
               baseVsPrior,
               priorVsCurrent)
           && !canCopyBasedOnCopyCondition(
-              notes, psa, patchSet, type.get(), changeKind, rw, repoConfig)) {
+              notes, psa, patchSet, type.get(), changeKind, isMerge, rw, repoConfig)) {
+        outdatedApprovalsBuilder.add(psa);
         continue;
       }
-      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
+      if (!currentApprovalsByUser.contains(psa.label(), psa.accountId())) {
+        copiedApprovalsByUser.put(
+            psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
+      }
     }
-    return resultByUser.values();
+
+    ImmutableSet<PatchSetApproval> copiedApprovals =
+        labelNormalizer.normalize(notes, copiedApprovalsByUser.values()).getNormalized();
+    return Result.create(copiedApprovals, outdatedApprovalsBuilder.build());
+  }
+
+  private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
+    try {
+      return rw.parseCommit(patchSet.commitId()).getParentCount() > 1;
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "failed to check if patch set %d of change %s in project %s is a merge commit",
+              patchSet.id().get(), patchSet.id().changeId(), project),
+          e);
+    }
   }
 
   /**
@@ -438,19 +505,12 @@
    * files between those two patch-sets .
    */
   private Map<String, ModifiedFile> listModifiedFiles(
-      ProjectState project, PatchSet ps, RevWalk revWalk, Config repoConfig) {
+      Project.NameKey projectName, PatchSet ps, RevWalk revWalk, Config repoConfig) {
     try {
       Integer parentNum =
-          listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
-              ? 0
-              : 1;
+          listOfFilesUnchangedPredicate.isInitialCommit(projectName, ps.commitId()) ? 0 : 1;
       return diffOperations.loadModifiedFilesAgainstParent(
-          project.getNameKey(),
-          ps.commitId(),
-          parentNum,
-          DiffOptions.DEFAULTS,
-          revWalk,
-          repoConfig);
+          projectName, ps.commitId(), parentNum, DiffOptions.DEFAULTS, revWalk, repoConfig);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
@@ -465,19 +525,14 @@
    * change.
    */
   private Map<String, ModifiedFile> listModifiedFiles(
-      ProjectState project,
+      Project.NameKey projectName,
       ObjectId sourceCommit,
       ObjectId targetCommit,
       RevWalk revWalk,
       Config repoConfig) {
     try {
       return diffOperations.loadModifiedFiles(
-          project.getNameKey(),
-          sourceCommit,
-          targetCommit,
-          DiffOptions.DEFAULTS,
-          revWalk,
-          repoConfig);
+          projectName, sourceCommit, targetCommit, DiffOptions.DEFAULTS, revWalk, repoConfig);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index 227ff70..c040e0b 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -18,15 +18,20 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -57,6 +62,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -95,7 +101,7 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalInference approvalInference;
+  private final ApprovalCopier approvalCopier;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final LabelNormalizer labelNormalizer;
@@ -103,11 +109,11 @@
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalInference approvalInference,
+      ApprovalCopier approvalCopier,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       LabelNormalizer labelNormalizer) {
-    this.approvalInference = approvalInference;
+    this.approvalCopier = approvalCopier;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.labelNormalizer = labelNormalizer;
@@ -336,26 +342,83 @@
 
   public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
       ChangeNotes notes) {
-    return notes.load().getApprovals();
+    return notes.load().getApprovals().onlyNonCopied();
   }
 
   /**
-   * This method should only be used when we want to dynamically compute the approvals. Generally,
-   * the copied approvals are available in {@link ChangeNotes}. However, if the patch-set is just
-   * being created, we need to dynamically compute the approvals so that we can persist them in
-   * storage. The {@link RevWalk} and {@link Config} objects that are being used to create the new
-   * patch-set are required for this method. Here we also add those votes to the provided {@link
-   * ChangeUpdate} object.
+   * Copies approvals to a new patch set.
+   *
+   * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and
+   * stores them in NoteDb.
+   *
+   * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch
+   * set and hence not copied) the approvers are added to the attention set since they need to
+   * re-review the change and renew their approvals.
+   *
+   * @param notes the change notes
+   * @param patchSet the newly created patch set
+   * @param revWalk {@link RevWalk} that can see the new patch set revision
+   * @param repoConfig the repo config
+   * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
+   *     attention set
+   * @return the result of the approval copying
    */
-  public void persistCopiedApprovals(
+  public ApprovalCopier.Result copyApprovalsToNewPatchSet(
       ChangeNotes notes,
       PatchSet patchSet,
       RevWalk revWalk,
       Config repoConfig,
       ChangeUpdate changeUpdate) {
-    approvalInference
-        .forPatchSet(notes, patchSet, revWalk, repoConfig)
-        .forEach(a -> changeUpdate.putCopiedApproval(a));
+    ApprovalCopier.Result approvalCopierResult =
+        approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
+    approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
+
+    if (!notes.getChange().isWorkInProgress()) {
+      // The attention set should not be updated when the change is work-in-progress.
+      addAttentionSetUpdatesForOutdatedApprovals(
+          changeUpdate, approvalCopierResult.outdatedApprovals());
+    }
+
+    return approvalCopierResult;
+  }
+
+  private void addAttentionSetUpdatesForOutdatedApprovals(
+      ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+
+    Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
+    outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
+    for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
+        outdatedApprovalsByUser.asMap().entrySet()) {
+      Account.Id approverId = e.getKey();
+      Collection<PatchSetApproval> outdatedUserApprovals = e.getValue();
+
+      String message;
+      if (outdatedUserApprovals.size() == 1) {
+        PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals);
+        message =
+            String.format(
+                "Vote got outdated and was removed: %s",
+                LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value())
+                    .format());
+      } else {
+        message =
+            String.format(
+                "Votes got outdated and were removed: %s",
+                outdatedUserApprovals.stream()
+                    .map(
+                        outdatedUserApproval ->
+                            LabelVote.create(
+                                    outdatedUserApproval.label(), outdatedUserApproval.value())
+                                .format())
+                    .sorted()
+                    .collect(joining(", ")));
+      }
+
+      updates.add(
+          AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message));
+    }
+    changeUpdate.addToPlannedAttentionSetUpdates(updates);
   }
 
   /**
@@ -368,7 +431,7 @@
    *     deleted labels.
    */
   public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovalsWithCopied().get(psId);
+    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
     return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
index f6527d2..89727c7 100644
--- a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 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.approval.testing;
 
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
index ef00b80..4270d1e 100644
--- a/java/com/google/gerrit/server/cache/PerThreadCache.java
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.java
@@ -21,7 +21,9 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import java.util.Map;
+import java.util.Optional;
 import java.util.function.Supplier;
+import javax.servlet.http.HttpServletRequest;
 
 /**
  * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
@@ -58,6 +60,12 @@
   private static final int PER_THREAD_CACHE_SIZE = 25;
 
   /**
+   * Optional HTTP request associated with the per-thread cache, should the thread be associated
+   * with the incoming HTTP thread pool.
+   */
+  private final Optional<HttpServletRequest> httpRequest;
+
+  /**
    * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
    * class and a list of identifiers that in combination uniquely set the object apart form others
    * of the same class.
@@ -102,9 +110,9 @@
     }
   }
 
-  public static PerThreadCache create() {
+  public static PerThreadCache create(@Nullable HttpServletRequest httpRequest) {
     checkState(CACHE.get() == null, "called create() twice on the same request");
-    PerThreadCache cache = new PerThreadCache();
+    PerThreadCache cache = new PerThreadCache(httpRequest);
     CACHE.set(cache);
     return cache;
   }
@@ -121,7 +129,9 @@
 
   private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
 
-  private PerThreadCache() {}
+  private PerThreadCache(@Nullable HttpServletRequest req) {
+    httpRequest = Optional.ofNullable(req);
+  }
 
   /**
    * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
@@ -139,6 +149,19 @@
     return value;
   }
 
+  /** Returns the optional HTTP request associated with the local thread cache. */
+  public Optional<HttpServletRequest> getHttpRequest() {
+    return httpRequest;
+  }
+
+  /** Returns true if there is an HTTP request associated and is a GET or HEAD */
+  public boolean hasReadonlyRequest() {
+    return httpRequest
+        .map(HttpServletRequest::getMethod)
+        .filter(m -> m.equalsIgnoreCase("GET") || m.equalsIgnoreCase("HEAD"))
+        .isPresent();
+  }
+
   @Override
   public void close() {
     CACHE.remove();
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
index 436fe76..d7091ca 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -28,9 +28,15 @@
 public class SubmitRequirementExpressionResultSerializer {
   public static SubmitRequirementExpressionResult deserialize(
       SubmitRequirementExpressionResultProto proto) {
+    SubmitRequirementExpressionResult.Status status;
+    try {
+      status = SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus());
+    } catch (IllegalArgumentException e) {
+      status = SubmitRequirementExpressionResult.Status.ERROR;
+    }
     return SubmitRequirementExpressionResult.create(
         SubmitRequirementExpression.create(proto.getExpression()),
-        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        status,
         proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
         proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()),
         Optional.ofNullable(Strings.emptyToNull(proto.getErrorMessage())));
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 29bd045..59b32c2 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -40,10 +40,7 @@
 
   private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  // Provider is needed, because AbandonUtil is singleton, but ChangeQueryBuilder accesses
-  // index collection, that is only provided when multiversion index module is started.
-  // TODO(davido); Remove provider again, when support for legacy numeric fields is removed.
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final BatchAbandon batchAbandon;
   private final InternalUser internalUser;
 
@@ -52,11 +49,11 @@
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       BatchAbandon batchAbandon) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
@@ -74,11 +71,7 @@
       }
 
       List<ChangeData> changesToAbandon =
-          queryProvider
-              .get()
-              .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(query))
-              .entities();
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
@@ -118,7 +111,7 @@
           queryProvider
               .get()
               .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(newQuery))
+              .query(queryBuilder.parse(newQuery))
               .entities();
       if (!changesToAbandon.isEmpty()) {
         validChanges.add(cd);
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index a980c32..1cf31c1 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -31,18 +29,14 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 
 /** Add a specified user to the attention set. */
 public class AddToAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -63,14 +57,12 @@
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
       AddToAttentionSetSender.Factory addToAttentionSetSender,
-      MessageIdGenerator messageIdGenerator,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
     this.addToAttentionSetSender = addToAttentionSetSender;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
 
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6ef7f1e..edaca70 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -467,10 +467,10 @@
     approvalsUtil.addApprovalsForNewPatchSet(
         update, labelTypes, patchSet, ctx.getUser(), approvals);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
-    // TODO(dborowitz): Still necessary?
     if (!approvals.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 0116b01..1199be5 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -228,7 +228,7 @@
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
-    approvals = ctx.getNotes().getApprovalsWithCopied().values();
+    approvals = ctx.getNotes().getApprovals().all().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
new file mode 100644
index 0000000..f6ae6a3
--- /dev/null
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -0,0 +1,241 @@
+// 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.change;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class EmailNewPatchSet {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    EmailNewPatchSet create(
+        PostUpdateContext postUpdateContext,
+        PatchSet patchSet,
+        String message,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+        @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId);
+  }
+
+  private final ExecutorService sendEmailExecutor;
+  private final ThreadLocalRequestContext threadLocalRequestContext;
+  private final AsyncSender asyncSender;
+
+  private RequestScopePropagator requestScopePropagator;
+
+  @Inject
+  EmailNewPatchSet(
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ThreadLocalRequestContext threadLocalRequestContext,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      PatchSetInfoFactory patchSetInfoFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted PostUpdateContext postUpdateContext,
+      @Assisted PatchSet patchSet,
+      @Assisted String message,
+      @Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
+      @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+      @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId) {
+    this.sendEmailExecutor = sendEmailExecutor;
+    this.threadLocalRequestContext = threadLocalRequestContext;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReplacePatchSet");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            postUpdateContext.getIdentifiedUser(),
+            replacePatchSetFactory,
+            patchSetInfoFactory,
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            message,
+            postUpdateContext.getWhen(),
+            outdatedApprovals,
+            reviewers,
+            extraCcs,
+            changeKind,
+            preUpdateMetaId,
+            postUpdateSubmitRequirementResults);
+  }
+
+  public EmailNewPatchSet setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator != null
+                ? requestScopePropagator.wrap(asyncSender)
+                : () -> {
+                  RequestContext old = threadLocalRequestContext.setContext(asyncSender);
+                  try {
+                    asyncSender.run();
+                  } finally {
+                    threadLocalRequestContext.setContext(old);
+                  }
+                });
+  }
+
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final IdentifiedUser user;
+    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableSet<PatchSetApproval> outdatedApprovals;
+    private final ImmutableSet<Account.Id> reviewers;
+    private final ImmutableSet<Account.Id> extraCcs;
+    private final ChangeKind changeKind;
+    private final ObjectId preUpdateMetaId;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        IdentifiedUser user,
+        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        String message,
+        Instant timestamp,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        ImmutableSet<Account.Id> reviewers,
+        ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.user = user;
+      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.outdatedApprovals = outdatedApprovals;
+      this.reviewers = reviewers;
+      this.extraCcs = extraCcs;
+      this.changeKind = changeKind;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender emailSender =
+            replacePatchSetFactory.create(
+                projectName,
+                changeId,
+                changeKind,
+                preUpdateMetaId,
+                postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setNotify(notify);
+        emailSender.addReviewers(reviewers);
+        emailSender.addExtraCC(extraCcs);
+        emailSender.addOutdatedApproval(outdatedApprovals);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f94e592..a9886c7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -16,29 +16,38 @@
 
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.time.Instant;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
 
-public class EmailReviewComments implements Runnable, RequestContext {
+public class EmailReviewComments {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -47,13 +56,11 @@
     /**
      * Creates handle for sending email
      *
-     * @param notify setting for handling notification.
-     * @param notes change notes.
+     * @param postUpdateContext the post update context from the calling BatchUpdateOp
      * @param patchSet patch set corresponding to the top-level op
-     * @param user user the email should come from.
+     * @param preUpdateMetaId the SHA1 to which the notes branch pointed before the update
      * @param message used by text template only. The contents of this message typically include the
      *     "Patch set N" header and "(M comments)".
-     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
@@ -61,34 +68,17 @@
      * @param labels labels applied as part of this review operation.
      */
     EmailReviewComments create(
-        NotifyResolver.Result notify,
-        ChangeNotes notes,
+        PostUpdateContext postUpdateContext,
         PatchSet patchSet,
-        IdentifiedUser user,
+        ObjectId preUpdateMetaId,
         @Assisted("message") String message,
-        Instant timestamp,
         List<? extends Comment> comments,
-        @Assisted("patchSetComment") String patchSetComment,
-        List<LabelVote> labels,
-        RepoView repoView);
+        @Nullable @Assisted("patchSetComment") String patchSetComment,
+        List<LabelVote> labels);
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final CommentSender.Factory commentSenderFactory;
-  private final ThreadLocalRequestContext requestContext;
-  private final MessageIdGenerator messageIdGenerator;
-
-  private final NotifyResolver.Result notify;
-  private final ChangeNotes notes;
-  private final PatchSet patchSet;
-  private final IdentifiedUser user;
-  private final String message;
-  private final Instant timestamp;
-  private final List<? extends Comment> comments;
-  private final String patchSetComment;
-  private final List<LabelVote> labels;
-  private final RepoView repoView;
+  private final AsyncSender asyncSender;
 
   @Inject
   EmailReviewComments(
@@ -97,69 +87,151 @@
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
-      @Assisted NotifyResolver.Result notify,
-      @Assisted ChangeNotes notes,
+      @Assisted PostUpdateContext postUpdateContext,
       @Assisted PatchSet patchSet,
-      @Assisted IdentifiedUser user,
+      @Assisted ObjectId preUpdateMetaId,
       @Assisted("message") String message,
-      @Assisted Instant timestamp,
       @Assisted List<? extends Comment> comments,
       @Nullable @Assisted("patchSetComment") String patchSetComment,
-      @Assisted List<LabelVote> labels,
-      @Assisted RepoView repoView) {
+      @Assisted List<LabelVote> labels) {
     this.sendEmailsExecutor = executor;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.commentSenderFactory = commentSenderFactory;
-    this.requestContext = requestContext;
-    this.messageIdGenerator = messageIdGenerator;
-    this.notify = notify;
-    this.notes = notes;
-    this.patchSet = patchSet;
-    this.user = user;
-    this.message = message;
-    this.timestamp = timestamp;
-    this.comments = COMMENT_ORDER.sortedCopy(comments);
-    this.patchSetComment = patchSetComment;
-    this.labels = labels;
-    this.repoView = repoView;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReviewComments");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            commentSenderFactory,
+            patchSetInfoFactory,
+            postUpdateContext.getUser().asIdentifiedUser(),
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            preUpdateMetaId,
+            message,
+            postUpdateContext.getWhen(),
+            ImmutableList.copyOf(COMMENT_ORDER.sortedCopy(comments)),
+            patchSetComment,
+            ImmutableList.copyOf(labels),
+            postUpdateSubmitRequirementResults);
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    RequestContext old = requestContext.setContext(this);
-    try {
-      CommentSender emailSender =
-          commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
-      emailSender.setFrom(user.getAccountId());
-      emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message, timestamp);
-      emailSender.setComments(comments);
-      emailSender.setPatchSetComment(patchSetComment);
-      emailSender.setLabels(labels);
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdateAndReason(
-              repoView, patchSet.id(), "EmailReviewComments"));
-      emailSender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
-    } finally {
-      requestContext.setContext(old);
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  // TODO: The passed in Comment class is not thread-safe, replace it with an AutoValue type.
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final CommentSender.Factory commentSenderFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final IdentifiedUser user;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final ObjectId preUpdateMetaId;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableList<? extends Comment> comments;
+    @Nullable private final String patchSetComment;
+    private final ImmutableList<LabelVote> labels;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        CommentSender.Factory commentSenderFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        IdentifiedUser user,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        ObjectId preUpdateMetaId,
+        String message,
+        Instant timestamp,
+        ImmutableList<? extends Comment> comments,
+        @Nullable String patchSetComment,
+        ImmutableList<LabelVote> labels,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.requestContext = requestContext;
+      this.commentSenderFactory = commentSenderFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.user = user;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.comments = comments;
+      this.patchSetComment = patchSetComment;
+      this.labels = labels;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        CommentSender emailSender =
+            commentSenderFactory.create(
+                projectName, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setComments(comments);
+        emailSender.setPatchSetComment(patchSetComment);
+        emailSender.setLabels(labels);
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return user.getRealUser();
+    @Override
+    public String toString() {
+      return "send-email comments";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index aeb9db0..79e2054 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -32,8 +32,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.List;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -49,23 +50,25 @@
   public abstract static class Result {
     @VisibleForTesting
     static Result create(
-        List<PatchSetApproval> unchanged,
-        List<PatchSetApproval> updated,
-        List<PatchSetApproval> deleted) {
+        Set<PatchSetApproval> unchanged,
+        Set<PatchSetApproval> updated,
+        Set<PatchSetApproval> deleted) {
       return new AutoValue_LabelNormalizer_Result(
-          ImmutableList.copyOf(unchanged),
-          ImmutableList.copyOf(updated),
-          ImmutableList.copyOf(deleted));
+          ImmutableSet.copyOf(unchanged),
+          ImmutableSet.copyOf(updated),
+          ImmutableSet.copyOf(deleted));
     }
 
-    public abstract ImmutableList<PatchSetApproval> unchanged();
+    public abstract ImmutableSet<PatchSetApproval> unchanged();
 
-    public abstract ImmutableList<PatchSetApproval> updated();
+    public abstract ImmutableSet<PatchSetApproval> updated();
 
-    public abstract ImmutableList<PatchSetApproval> deleted();
+    public abstract ImmutableSet<PatchSetApproval> deleted();
 
-    public Iterable<PatchSetApproval> getNormalized() {
-      return Iterables.concat(unchanged(), updated());
+    public ImmutableSet<PatchSetApproval> getNormalized() {
+      return Streams.concat(unchanged().stream(), updated().stream())
+          .distinct()
+          .collect(toImmutableSet());
     }
   }
 
@@ -84,9 +87,9 @@
    * @param approvals list of approvals.
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals) {
-    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
+    Set<PatchSetApproval> unchanged = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> updated = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> deleted = new HashSet<>(approvals.size());
     LabelTypes labelTypes =
         projectCache
             .get(notes.getProjectName())
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index fc56e80..b09b3c7 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -21,11 +21,13 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.flogger.FluentLogger;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -39,8 +41,6 @@
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
@@ -65,8 +65,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class PatchSetInserter implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
   }
@@ -74,15 +72,15 @@
   // Injected fields.
   private final PermissionBackend permissionBackend;
   private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ChangeKindCache changeKindCache;
   private final CommitValidators.Factory commitValidatorsFactory;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
-  private final MessageIdGenerator messageIdGenerator;
   private final AutoMerger autoMerger;
 
   // Assisted-injected fields.
@@ -111,9 +109,12 @@
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
+  private ChangeKind changeKind;
   private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
+  private ImmutableSet<PatchSetApproval> outdatedApprovals;
+  private ObjectId preUpdateMetaId;
 
   @Inject
   public PatchSetInserter(
@@ -121,13 +122,13 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
+      ChangeKindCache changeKindCache,
       CommitValidators.Factory commitValidatorsFactory,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
-      MessageIdGenerator messageIdGenerator,
       AutoMerger autoMerger,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
@@ -136,13 +137,13 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.patchSetInfoFactory = patchSetInfoFactory;
+    this.changeKindCache = changeKindCache;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
-    this.messageIdGenerator = messageIdGenerator;
     this.autoMerger = autoMerger;
 
     this.origNotes = notes;
@@ -238,6 +239,15 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+
+    changeKind =
+        changeKindCache.getChangeKind(
+            ctx.getProject(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            psUtil.current(origNotes).commitId(),
+            commitId);
+
     Optional<ReceiveCommand> autoMerge =
         autoMerger.createAutoMergeCommitIfNecessary(
             ctx.getRepoView(),
@@ -252,6 +262,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setSubjectForCommit("Create patch set " + psId.get());
@@ -307,8 +318,11 @@
     }
 
     if (storeCopiedVotes) {
-      approvalsUtil.persistCopiedApprovals(
-          ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+      outdatedApprovals =
+          approvalsUtil
+              .copyApprovalsToNewPatchSet(
+                  ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update)
+              .outdatedApprovals();
     }
 
     return true;
@@ -319,22 +333,18 @@
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(mailMessage);
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.addReviewers(oldReviewers.byState(REVIEWER));
-        emailSender.addExtraCC(oldReviewers.byState(CC));
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-        emailSender.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for new patch set on change %s", change.getId());
-      }
+
+      emailNewPatchSetFactory
+          .create(
+              ctx,
+              patchSet,
+              mailMessage,
+              outdatedApprovals,
+              oldReviewers.byState(REVIEWER),
+              oldReviewers.byState(CC),
+              changeKind,
+              preUpdateMetaId)
+          .sendAsync();
     }
 
     if (fireRevisionCreated) {
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a0fa8e9..4de21d6 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -75,7 +76,7 @@
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
 
@@ -106,7 +107,7 @@
   @Inject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
       IdentifiedUser.GenericFactory identifiedUserFactory,
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 50ee9d4..9fb4fc4 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -32,19 +30,15 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -64,14 +58,12 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      MessageIdGenerator messageIdGenerator,
       RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.messageIdGenerator = messageIdGenerator;
     this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 0321fcb..5469b51 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -57,7 +57,7 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -87,7 +87,7 @@
     RevisionJson create(Iterable<ListChangesOption> options);
   }
 
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final FileInfoJson fileInfoJson;
   private final GpgApiAdapter gpgApi;
@@ -111,7 +111,7 @@
       AnonymousUser anonymous,
       ProjectCache projectCache,
       IdentifiedUser.GenericFactory userFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       FileInfoJson fileInfoJson,
       AccountLoader.Factory accountLoaderFactory,
       DynamicMap<DownloadScheme> downloadSchemes,
@@ -168,7 +168,8 @@
       RevCommit commit,
       boolean addLinks,
       boolean fillCommit,
-      String branchName)
+      String branchName,
+      String changeKey)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -182,7 +183,8 @@
 
     if (addLinks) {
       ImmutableList<WebLinkInfo> patchSetLinks =
-          webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
+          webLinks.getPatchSetLinks(
+              project, commit.name(), commit.getFullMessage(), branchName, changeKey);
       info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
       ImmutableList<WebLinkInfo> resolveConflictsLinks =
           webLinks.getResolveConflictsLinks(
@@ -301,7 +303,9 @@
       rw.parseBody(commit);
       String branchName = cd.change().getDest().branch();
       if (setCommit) {
-        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit, branchName);
+        out.commit =
+            getCommitInfo(
+                project, rw, commit, has(WEB_LINKS), fillCommit, branchName, c.getKey().get());
       }
       if (addFooters) {
         Ref ref = repo.exactRef(branchName);
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index 96c863e..fcd9e90 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -65,7 +65,10 @@
       boolean hide) {
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
     info.expression = hide ? null : expression.expressionString();
-    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.fulfilled =
+        result.status().equals(SubmitRequirementExpressionResult.Status.PASS)
+            || result.status().equals(SubmitRequirementExpressionResult.Status.NOT_EVALUATED);
+    info.status = SubmitRequirementExpressionInfo.Status.valueOf(result.status().name());
     info.passingAtoms = hide ? null : result.passingAtoms();
     info.failingAtoms = hide ? null : result.failingAtoms();
     info.errorMessage = result.errorMessage().isPresent() ? result.errorMessage().get() : null;
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 1409170..04fd1c0 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -19,21 +19,18 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -61,8 +58,8 @@
   private final WorkInProgressStateChanged stateChanged;
 
   private boolean sendEmail = true;
+  private ObjectId preUpdateMetaId;
   private Change change;
-  private ChangeNotes notes;
   private PatchSet ps;
   private String mailMessage;
 
@@ -88,8 +85,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx) {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
-    notes = ctx.getNotes();
     ps = psUtil.get(ctx.getNotes(), change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setWorkInProgress(workInProgress);
@@ -131,25 +128,15 @@
         || !sendEmail) {
       return;
     }
-    RepoView repoView;
-    try {
-      repoView = ctx.getRepoView();
-    } catch (IOException ex) {
-      throw new StorageException(
-          String.format("Repository %s not found", ctx.getProject().get()), ex);
-    }
     email
         .create(
-            notify,
-            notes,
+            ctx,
             ps,
-            ctx.getIdentifiedUser(),
-            mailMessage,
-            ctx.getWhen(),
-            ImmutableList.of(),
+            preUpdateMetaId,
             mailMessage,
             ImmutableList.of(),
-            repoView)
+            mailMessage,
+            ImmutableList.of())
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
index af2ae92..ce9aa78 100644
--- a/java/com/google/gerrit/server/comment/CommentContextKey.java
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -1,3 +1,17 @@
+// 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.
+
 package com.google.gerrit.server.comment;
 
 import com.google.auto.value.AutoValue;
diff --git a/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
index 27ae41f..0e377d3 100644
--- a/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 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.config;
 
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
index ebb0e50..db21e1f 100644
--- a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 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.config;
 
 import com.google.common.annotations.VisibleForTesting;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 86d81e7..a750d8e 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
@@ -120,6 +121,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -133,7 +135,6 @@
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.NotesBranchUtil;
@@ -296,7 +297,7 @@
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
-    factory(MergeUtil.Factory.class);
+    factory(EmailNewPatchSet.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
@@ -352,6 +353,7 @@
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
     DynamicSet.setOf(binder(), ChangeDeletedListener.class);
@@ -387,7 +389,7 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
+    DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReindexAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 38a86f0..c477bb5 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -318,11 +318,13 @@
     }
 
     @Override
-    public WebLinkInfo getFileWebLink(String projectName, String revision, String fileName) {
+    public WebLinkInfo getFileWebLink(
+        String projectName, String revision, String hash, String fileName) {
       if (file != null) {
         return link(
             file.replace("project", encode(projectName))
                 .replace("commit", encode(revision))
+                .replace("hash", encode(hash))
                 .replace("file", encode(fileName))
                 .toString());
       }
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 86a9f69..9deea8a 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,6 +22,8 @@
   /** Features that are known experiments and can be referenced in the code. */
   public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
 
+  public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
+
   public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
       "GerritBackendRequestFeature__remove_revision_etag";
 
@@ -32,7 +34,16 @@
   public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
       "GerritBackendRequestFeature__compute_from_all_users_repository";
 
+  /**
+   * When set, the result status of submit requirement expressions might hold the value
+   * "NOT_EVALUATED". For example if the change is not applicable, the submit / override expression
+   * results will be set to this value. If not set, the submit / override expressions will be set to
+   * empty optionals.
+   */
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED =
+      "GerritBackendRequestFeature__sr_expressions_not_evaluated";
+
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
-      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
+      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
 }
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 45f7ecb..b669571 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -98,7 +99,7 @@
     return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
   }
 
-  public AccountInfo accountInfo(AccountState accountState) {
+  public AccountInfo accountInfo(@Nullable AccountState accountState) {
     if (accountState == null || accountState.account().id() == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 6ed0a08..814390b 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -17,11 +17,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -58,17 +62,23 @@
             Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {}
       };
 
-  private final PluginSetContext<GitReferenceUpdatedListener> listeners;
+  private final PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners;
+  private final PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners;
   private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(PluginSetContext<GitReferenceUpdatedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
+  GitReferenceUpdated(
+      PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners,
+      PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners,
+      EventUtil util) {
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
+    this.refUpdatedListeners = refUpdatedListeners;
     this.util = util;
   }
 
   private GitReferenceUpdated() {
-    this.listeners = null;
+    this.batchRefUpdateListeners = null;
+    this.refUpdatedListeners = null;
     this.util = null;
   }
 
@@ -79,20 +89,19 @@
       AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        type,
+        new UpdatedRef(
+            refUpdate.getName(), refUpdate.getOldObjectId(), refUpdate.getNewObjectId(), type),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, RefUpdate refUpdate, AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(
+            refUpdate.getName(),
+            refUpdate.getOldObjectId(),
+            refUpdate.getNewObjectId(),
+            ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
@@ -104,83 +113,80 @@
       AccountState updater) {
     fire(
         project,
-        ref,
-        oldObjectId,
-        newObjectId,
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, ReceiveCommand cmd, AccountState updater) {
     fire(
         project,
-        cmd.getRefName(),
-        cmd.getOldId(),
-        cmd.getNewId(),
-        cmd.getType(),
+        new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.isEmpty() && refUpdatedListeners.isEmpty()) {
       return;
     }
+    Set<GitBatchRefUpdateListener.UpdatedRef> updates = new HashSet<>();
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(
-            project,
-            cmd.getRefName(),
-            cmd.getOldId(),
-            cmd.getNewId(),
-            cmd.getType(),
-            util.accountInfo(updater));
+        updates.add(
+            new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()));
       }
     }
+    fireBatchRefUpdateEvent(project, updates, util.accountInfo(updater));
+    fireRefUpdatedEvents(project, updates, util.accountInfo(updater));
   }
 
-  private void fire(
+  private void fire(Project.NameKey project, UpdatedRef updatedRef, AccountInfo updater) {
+    fireBatchRefUpdateEvent(project, Set.of(updatedRef), updater);
+    fireRefUpdatedEvent(project, updatedRef, updater);
+  }
+
+  private void fireBatchRefUpdateEvent(
       Project.NameKey project,
-      String ref,
-      ObjectId oldObjectId,
-      ObjectId newObjectId,
-      ReceiveCommand.Type type,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
       AccountInfo updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.isEmpty()) {
       return;
     }
-    ObjectId o = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
-    ObjectId n = newObjectId != null ? newObjectId : ObjectId.zeroId();
-    Event event = new Event(project, ref, o.name(), n.name(), type, updater);
-    listeners.runEach(l -> l.onGitReferenceUpdated(event));
+    GitBatchRefUpdateEvent event = new GitBatchRefUpdateEvent(project, updatedRefs, updater);
+    batchRefUpdateListeners.runEach(l -> l.onGitBatchRefUpdate(event));
   }
 
-  /** Event to be fired when a Git reference has been updated. */
-  public static class Event implements GitReferenceUpdatedListener.Event {
-    private final String projectName;
-    private final String ref;
-    private final String oldObjectId;
-    private final String newObjectId;
-    private final ReceiveCommand.Type type;
-    private final AccountInfo updater;
-
-    Event(
-        Project.NameKey project,
-        String ref,
-        String oldObjectId,
-        String newObjectId,
-        ReceiveCommand.Type type,
-        AccountInfo updater) {
-      this.projectName = project.get();
-      this.ref = ref;
-      this.oldObjectId = oldObjectId;
-      this.newObjectId = newObjectId;
-      this.type = type;
-      this.updater = updater;
+  private void fireRefUpdatedEvents(
+      Project.NameKey project,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+      AccountInfo updater) {
+    for (GitBatchRefUpdateListener.UpdatedRef updatedRef : updatedRefs) {
+      fireRefUpdatedEvent(project, updatedRef, updater);
     }
+  }
 
-    @Override
-    public String getProjectName() {
-      return projectName;
+  private void fireRefUpdatedEvent(
+      Project.NameKey project,
+      GitBatchRefUpdateListener.UpdatedRef updatedRef,
+      AccountInfo updater) {
+    if (refUpdatedListeners.isEmpty()) {
+      return;
+    }
+    GitReferenceUpdatedEvent event = new GitReferenceUpdatedEvent(project, updatedRef, updater);
+    refUpdatedListeners.runEach(l -> l.onGitReferenceUpdated(event));
+  }
+
+  public static class UpdatedRef implements GitBatchRefUpdateListener.UpdatedRef {
+    private final String ref;
+    private final ObjectId oldObjectId;
+    private final ObjectId newObjectId;
+    private final ReceiveCommand.Type type;
+
+    public UpdatedRef(
+        String ref, ObjectId oldObjectId, ObjectId newObjectId, ReceiveCommand.Type type) {
+      this.ref = ref;
+      this.oldObjectId = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
+      this.newObjectId = newObjectId != null ? newObjectId : ObjectId.zeroId();
+      this.type = type;
     }
 
     @Override
@@ -190,12 +196,12 @@
 
     @Override
     public String getOldObjectId() {
-      return oldObjectId;
+      return oldObjectId.name();
     }
 
     @Override
     public String getNewObjectId() {
-      return newObjectId;
+      return newObjectId.name();
     }
 
     @Override
@@ -214,15 +220,51 @@
     }
 
     @Override
+    public String toString() {
+      return String.format("{%s: %s -> %s}", ref, oldObjectId, newObjectId);
+    }
+  }
+
+  /** Event to be fired when a Git reference has been updated. */
+  public static class GitBatchRefUpdateEvent implements GitBatchRefUpdateListener.Event {
+    private final String projectName;
+    private final Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs;
+    private final AccountInfo updater;
+
+    public GitBatchRefUpdateEvent(
+        Project.NameKey project,
+        Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRefs = updatedRefs;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public Set<GitBatchRefUpdateListener.UpdatedRef> getUpdatedRefs() {
+      return updatedRefs;
+    }
+
+    @Override
+    public Set<String> getRefNames() {
+      return updatedRefs.stream()
+          .map(GitBatchRefUpdateListener.UpdatedRef::getRefName)
+          .collect(Collectors.toSet());
+    }
+
+    @Override
     public AccountInfo getUpdater() {
       return updater;
     }
 
     @Override
     public String toString() {
-      return String.format(
-          "%s[%s,%s: %s -> %s]",
-          getClass().getSimpleName(), projectName, ref, oldObjectId, newObjectId);
+      return String.format("%s[%s,%s]", getClass().getSimpleName(), projectName, updatedRefs);
     }
 
     @Override
@@ -230,4 +272,65 @@
       return NotifyHandling.ALL;
     }
   }
+
+  public static class GitReferenceUpdatedEvent implements GitReferenceUpdatedListener.Event {
+
+    private final String projectName;
+    private final GitBatchRefUpdateListener.UpdatedRef updatedRef;
+    private final AccountInfo updater;
+
+    public GitReferenceUpdatedEvent(
+        Project.NameKey project,
+        GitBatchRefUpdateListener.UpdatedRef updatedRef,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRef = updatedRef;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public NotifyHandling getNotify() {
+      return NotifyHandling.ALL;
+    }
+
+    @Override
+    public String getRefName() {
+      return updatedRef.getRefName();
+    }
+
+    @Override
+    public String getOldObjectId() {
+      return updatedRef.getOldObjectId();
+    }
+
+    @Override
+    public String getNewObjectId() {
+      return updatedRef.getNewObjectId();
+    }
+
+    @Override
+    public boolean isCreate() {
+      return updatedRef.isCreate();
+    }
+
+    @Override
+    public boolean isDelete() {
+      return updatedRef.isDelete();
+    }
+
+    @Override
+    public boolean isNonFastForward() {
+      return updatedRef.isNonFastForward();
+    }
+
+    @Override
+    public AccountInfo getUpdater() {
+      return updater;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index deaaff8..d127260 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -58,7 +59,7 @@
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
       String message,
-      AccountState remover,
+      @Nullable AccountState remover,
       Instant when) {
     if (listeners.isEmpty()) {
       return;
@@ -69,8 +70,8 @@
               util.changeInfo(changeData),
               util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
-              util.approvals(remover, approvals, when),
-              util.approvals(remover, oldApprovals, when),
+              util.approvals(reviewer, approvals, when),
+              util.approvals(reviewer, oldApprovals, when),
               notify,
               message,
               util.accountInfo(remover),
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index 9ea628e..df20fbf 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -355,6 +355,11 @@
     }
 
     void processLineToColumn(int to, boolean append) throws IndexOutOfBoundsException {
+      int from = srcPosition.column;
+      if (from > to) {
+        throw new IndexOutOfBoundsException(
+            String.format("The parameter from is greater than to. from: %d, to: %d", from, to));
+      }
       if (to == 0) {
         return;
       }
@@ -366,7 +371,6 @@
           throw new IndexOutOfBoundsException("The processLineToColumn shouldn't add end of line");
         }
       }
-      int from = srcPosition.column;
       int charCount = to - from;
       srcPosition.appendStringWithoutEOLMark(charCount);
       if (append) {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index e52c45f..b04fbf8 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -57,6 +58,7 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -274,6 +276,7 @@
             .create(changeId, revertCommit, notes.getChange().getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
+    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -298,6 +301,20 @@
     return changeId;
   }
 
+  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
   private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index d84ce7b..ae247ad 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -23,6 +23,8 @@
 import static java.util.Comparator.naturalOrder;
 import static java.util.stream.Collectors.joining;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -60,8 +62,6 @@
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.submit.MergeSorter;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -115,6 +115,7 @@
  * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
  * {@code BatchUpdate}.
  */
+@AutoFactory
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -135,12 +136,6 @@
     return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
   }
 
-  public interface Factory {
-    MergeUtil create(ProjectState project);
-
-    MergeUtil create(ProjectState project, boolean useContentMerge);
-  }
-
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final ApprovalsUtil approvalsUtil;
@@ -149,40 +144,38 @@
   private final boolean useRecursiveMerge;
   private final PluggableCommitMessageGenerator commitMessageGenerator;
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted ProjectState project) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project) {
     this(
         serverConfig,
         identifiedUserFactory,
         urlFormatter,
         approvalsUtil,
-        project,
         commitMessageGenerator,
+        project,
         project.is(BooleanProjectConfig.USE_CONTENT_MERGE));
   }
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      @Assisted ProjectState project,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted boolean useContentMerge) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project,
+      boolean useContentMerge) {
     this.identifiedUserFactory = identifiedUserFactory;
     this.urlFormatter = urlFormatter;
     this.approvalsUtil = approvalsUtil;
+    this.commitMessageGenerator = commitMessageGenerator;
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
-    this.commitMessageGenerator = commitMessageGenerator;
   }
 
   public CodeReviewCommit getFirstFastForward(
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 52a34d9..290e1e7 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -249,6 +249,7 @@
    * @param out stream for writing progress messages.
    * @param taskName name of the overall task.
    */
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 3910393..90eadf3 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -138,13 +138,13 @@
 
   static class Loader extends CacheLoader<PureRevertKeyProto, Boolean> {
     private final GitRepositoryManager repoManager;
-    private final MergeUtil.Factory mergeUtilFactory;
+    private final MergeUtilFactory mergeUtilFactory;
     private final ProjectCache projectCache;
 
     @Inject
     Loader(
         GitRepositoryManager repoManager,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         ProjectCache projectCache) {
       this.repoManager = repoManager;
       this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/git/RepoRefCache.java b/java/com/google/gerrit/server/git/RepoRefCache.java
index c69f9a6..7f22111 100644
--- a/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.server.cache.PerThreadCache;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
@@ -29,6 +30,17 @@
   private final RefDatabase refdb;
   private final Map<String, Optional<ObjectId>> ids;
 
+  public static Optional<RefCache> getOptional(Repository repo) {
+    PerThreadCache cache = PerThreadCache.get();
+    if (cache != null && cache.hasReadonlyRequest()) {
+      return Optional.of(
+          cache.get(
+              PerThreadCache.Key.create(RepoRefCache.class, repo), () -> new RepoRefCache(repo)));
+    }
+
+    return Optional.empty();
+  }
+
   public RepoRefCache(Repository repo) {
     this.refdb = repo.getRefDatabase();
     this.ids = new HashMap<>();
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index ff5bcc2..e2f9abd 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -45,6 +45,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
 
 /**
  * Cache based on an index query of the most recent changes. The number of cached items depends on
@@ -116,22 +117,23 @@
    * Additional stored fields are not loaded from the index.
    *
    * @param project project to read.
-   * @return list of known changes; empty if no changes.
+   * @return stream of known changes; empty if no changes.
    */
-  public List<ChangeData> getChangeData(Project.NameKey project) {
+  public Stream<ChangeData> getChangeData(Project.NameKey project) {
+    List<CachedChange> cached;
     try {
-      List<CachedChange> cached = cache.get(project);
-      List<ChangeData> cds = new ArrayList<>(cached.size());
-      for (CachedChange cc : cached) {
-        ChangeData cd = changeDataFactory.create(cc.change());
-        cd.setReviewers(cc.reviewers());
-        cds.add(cd);
-      }
-      return Collections.unmodifiableList(cds);
+      cached = cache.get(project);
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot fetch changes for %s", project);
-      return Collections.emptyList();
+      return Stream.empty();
     }
+    return cached.stream()
+        .map(
+            cc -> {
+              ChangeData cd = changeDataFactory.create(cc.change());
+              cd.setReviewers(cc.reviewers());
+              return cd;
+            });
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 889dfd6..cab2960 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -1007,6 +1007,7 @@
             .setIsWorkInProgress(wip)
             .build();
     addMessage(changeFormatter.changeUpdated(input));
+    u.getOutdatedApprovalsMessage().map(msg -> "\n" + msg).ifPresent(this::addMessage);
   }
 
   private void insertChangesAndPatchSets(
@@ -3187,22 +3188,20 @@
 
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
         replaceOp =
-            replaceOpFactory
-                .create(
-                    projectState,
-                    notes.getChange().getDest(),
-                    checkMergedInto,
-                    checkMergedInto ? inputCommand.getNewId().name() : null,
-                    priorPatchSet,
-                    priorCommit,
-                    psId,
-                    newCommit,
-                    info,
-                    groups,
-                    magicBranch,
-                    receivePack.getPushCertificate(),
-                    notes.getChange())
-                .setRequestScopePropagator(requestScopePropagator);
+            replaceOpFactory.create(
+                projectState,
+                notes.getChange(),
+                checkMergedInto,
+                checkMergedInto ? inputCommand.getNewId().name() : null,
+                priorPatchSet,
+                priorCommit,
+                psId,
+                newCommit,
+                info,
+                groups,
+                magicBranch,
+                receivePack.getPushCertificate(),
+                requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -3232,6 +3231,10 @@
     String getRejectMessage() {
       return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
+
+    Optional<String> getOutdatedApprovalsMessage() {
+      return replaceOp != null ? replaceOp.getOutdatedApprovalsMessage() : Optional.empty();
+    }
   }
 
   private class UpdateGroupsRequest {
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index e7e0e8f..675709b 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -21,14 +21,16 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.joining;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
@@ -46,24 +48,25 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 import com.google.gerrit.server.change.ReviewerOp;
-import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -75,6 +78,7 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -86,8 +90,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -102,7 +104,7 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        BranchNameKey dest,
+        Change change,
         boolean checkMergedInto,
         @Nullable String mergeResultRevId,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -113,30 +115,29 @@
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
         @Nullable PushCertificate pushCertificate,
-        Change change);
+        RequestScopePropagator requestScopePropagator);
   }
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
+  private final AccountCache accountCache;
   private final AccountResolver accountResolver;
+  private final String anonymousCowardName;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
-  private final ExecutorService sendEmailExecutor;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetUtil psUtil;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerModifier reviewerModifier;
-  private final Change change;
-  private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final ProjectState projectState;
-  private final BranchNameKey dest;
+  private final Change change;
   private final boolean checkMergedInto;
   private final String mergeResultRevId;
   private final PatchSet.Id priorPatchSetId;
@@ -146,6 +147,7 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
+  private final RequestScopePropagator requestScopePropagator;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -155,15 +157,17 @@
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
   private String mailMessage;
+  private ImmutableSet<PatchSetApproval> outdatedApprovals;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
-  private RequestScopePropagator requestScopePropagator;
   private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
   ReplaceOp(
+      AccountCache accountCache,
       AccountResolver accountResolver,
+      @AnonymousCowardName String anonymousCowardName,
       ApprovalsUtil approvalsUtil,
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
@@ -172,15 +176,12 @@
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetUtil psUtil,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       ReviewerModifier reviewerModifier,
-      Change change,
-      MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
-      @Assisted BranchNameKey dest,
+      @Assisted Change change,
       @Assisted boolean checkMergedInto,
       @Assisted @Nullable String mergeResultRevId,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -190,8 +191,11 @@
       @Assisted PatchSetInfo info,
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
-      @Assisted @Nullable PushCertificate pushCertificate) {
+      @Assisted @Nullable PushCertificate pushCertificate,
+      @Assisted RequestScopePropagator requestScopePropagator) {
+    this.accountCache = accountCache;
     this.accountResolver = accountResolver;
+    this.anonymousCowardName = anonymousCowardName;
     this.approvalsUtil = approvalsUtil;
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
@@ -200,16 +204,13 @@
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
     this.psUtil = psUtil;
-    this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
-    this.sendEmailExecutor = sendEmailExecutor;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.reviewerModifier = reviewerModifier;
-    this.change = change;
-    this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
 
     this.projectState = projectState;
-    this.dest = dest;
+    this.change = change;
     this.checkMergedInto = checkMergedInto;
     this.mergeResultRevId = mergeResultRevId;
     this.priorPatchSetId = priorPatchSetId;
@@ -220,6 +221,7 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.requestScopePropagator = requestScopePropagator;
   }
 
   @Override
@@ -235,7 +237,7 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
+      String mergedInto = findMergedInto(ctx, change.getDest().branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
@@ -338,15 +340,23 @@
     }
     reviewerAdditions.updateChange(ctx, newPatchSet);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
     if (magicBranch != null && !magicBranch.labels.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    approvalsUtil.persistCopiedApprovals(
-        ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+    outdatedApprovals =
+        approvalsUtil
+            .copyApprovalsToNewPatchSet(
+                ctx.getNotes(),
+                newPatchSet,
+                ctx.getRevWalk(),
+                ctx.getRepoView().getConfig(),
+                update)
+            .outdatedApprovals();
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -489,16 +499,28 @@
   @Override
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      // TODO(dborowitz): Merge email templates so we only have to send one.
-      Runnable e = new ReplaceEmailTask(ctx);
-      if (requestScopePropagator != null) {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
-      } else {
-        e.run();
-      }
-    }
+
+    // TODO(dborowitz): Merge email templates so we only have to send one.
+    emailNewPatchSetFactory
+        .create(
+            ctx,
+            newPatchSet,
+            mailMessage,
+            outdatedApprovals,
+            Streams.concat(
+                    oldRecipients.getReviewers().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId))
+                .collect(toImmutableSet()),
+            Streams.concat(
+                    oldRecipients.getCcOnly().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
+                .collect(toImmutableSet()),
+            changeKind,
+            notes.getMetaId())
+        .setRequestScopePropagator(requestScopePropagator)
+        .sendAsync();
+
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     revisionCreated.fire(
         ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
@@ -512,49 +534,6 @@
     }
   }
 
-  private class ReplaceEmailTask implements Runnable {
-    private final Context ctx;
-
-    private ReplaceEmailTask(Context ctx) {
-      this.ctx = ctx;
-    }
-
-    @Override
-    public void run() {
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        emailSender.setFrom(ctx.getAccount().account().id());
-        emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
-        emailSender.addReviewers(
-            Streams.concat(
-                    oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::accountId))
-                .collect(toImmutableSet()));
-        emailSender.addExtraCC(
-            Streams.concat(
-                    oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
-                .collect(toImmutableSet()));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
-        // TODO(dborowitz): Support byEmail
-        emailSender.send();
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.id());
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "send-email newpatchset";
-    }
-  }
-
   private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
@@ -604,13 +583,42 @@
     return rejectMessage;
   }
 
-  public ReceiveCommand getCommand() {
-    return cmd;
+  public Optional<String> getOutdatedApprovalsMessage() {
+    if (outdatedApprovals == null || outdatedApprovals.isEmpty()) {
+      return Optional.empty();
+    }
+
+    return Optional.of(
+        "The following approvals got outdated and were removed:\n"
+            + outdatedApprovals.stream()
+                .map(
+                    outdatedApproval ->
+                        String.format(
+                            "* %s by %s",
+                            LabelVote.create(outdatedApproval.label(), outdatedApproval.value())
+                                .format(),
+                            getNameFor(outdatedApproval.accountId())))
+                .sorted()
+                .collect(joining("\n")));
   }
 
-  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
-    this.requestScopePropagator = requestScopePropagator;
-    return this;
+  private String getNameFor(Account.Id accountId) {
+    Optional<Account> account = accountCache.get(accountId).map(AccountState::account);
+    String name = null;
+    if (account.isPresent()) {
+      name = account.get().fullName();
+      if (name == null) {
+        name = account.get().preferredEmail();
+      }
+    }
+    if (name == null) {
+      name = anonymousCowardName + " #" + accountId;
+    }
+    return name;
+  }
+
+  public ReceiveCommand getCommand() {
+    return cmd;
   }
 
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 5f19758..c0e3471 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -22,6 +22,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
@@ -48,10 +49,12 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -104,6 +107,7 @@
     private final AccountValidator accountValidator;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final DiffOperations diffOperations;
     private final Config config;
 
     @Inject
@@ -118,7 +122,8 @@
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
         ProjectCache projectCache,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        DiffOperations diffOperations) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.config = config;
@@ -130,6 +135,7 @@
       this.accountValidator = accountValidator;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
+      this.diffOperations = diffOperations;
     }
 
     public CommitValidators forReceiveCommits(
@@ -161,7 +167,8 @@
           .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -190,7 +197,8 @@
           .add(new PluginCommitValidationListener(pluginValidators))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -371,14 +379,18 @@
       // If there are no SSH keys, the commit-msg hook must be installed via
       // HTTP(S)
       Optional<String> webUrl = urlFormatter.getWebUrl();
+
+      String httpHook =
+          String.format(
+              "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
+              webUrl.get());
+
       if (hostKeys.isEmpty()) {
         checkState(webUrl.isPresent());
-        return String.format(
-            "  f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
-            webUrl.get());
+        return httpHook;
       }
 
-      // SSH keys exist, so the hook can be installed with scp.
+      // SSH keys exist, so the hook might be able to be installed with scp.
       String sshHost;
       int sshPort;
       String host = hostKeys.get(0).getHost();
@@ -396,9 +408,11 @@
         sshPort = 22;
       }
 
-      return String.format(
-          "  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
-          sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      String sshHook =
+          String.format(
+              "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
+              sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      return String.format("  %s\nor, for http(s):\n  %s", sshHook, httpHook);
     }
   }
 
@@ -502,7 +516,7 @@
             for (ValidationError err : cfg.getValidationErrors()) {
               addError("  " + err.getMessage(), messages);
             }
-            throw new ConfigInvalidException("invalid project configuration");
+            throw new CommitValidationException("invalid project configuration", messages);
           }
           if (allUsers.equals(receiveEvent.project.getNameKey())
               && !allProjects.equals(cfg.getProject().getParent(allProjects))) {
@@ -510,9 +524,12 @@
             addError(
                 String.format("  %s must inherit from %s", allUsers.get(), allProjects.get()),
                 messages);
-            throw new ConfigInvalidException("invalid project configuration");
+            throw new CommitValidationException("invalid project configuration", messages);
           }
         } catch (ConfigInvalidException | IOException e) {
+          if (e instanceof ConfigInvalidException && !Strings.isNullOrEmpty(e.getMessage())) {
+            addError(e.getMessage(), messages);
+          }
           logger.atSevere().withCause(e).log(
               "User %s tried to push an invalid project configuration %s for project %s",
               user.getLoggableName(),
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 64e8788..85d232e 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -97,7 +97,7 @@
           new DisallowCreationAndDeletionOfGerritMaintainedBranches(perm, allUsersName)
               .onRefOperation(event));
       refOperationValidationListeners.runEach(
-          l -> l.onRefOperation(event), ValidationException.class);
+          l -> messages.addAll(l.onRefOperation(event)), ValidationException.class);
     } catch (ValidationException e) {
       messages.add(new ValidationMessage(e.getMessage(), true));
       withException = true;
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index ee8dfc8..fced578 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -15,21 +15,18 @@
 package com.google.gerrit.server.index;
 
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
@@ -71,11 +68,9 @@
 
   /**
    * Returns a sanitized set of fields for change index queries by removing fields that the current
-   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
-   * key situation is temporary and should be removed after the migration is done.
+   * index version doesn't support.
    */
-  public static Set<String> changeFields(QueryOptions opts, boolean useLegacyNumericFields) {
-    FieldDef<ChangeData, ?> idField = useLegacyNumericFields ? LEGACY_ID : LEGACY_ID_STR;
+  public static Set<String> changeFields(QueryOptions opts) {
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
@@ -84,10 +79,10 @@
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT.getName()) && fs.contains(idField.getName())) {
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID_STR.getName())) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(idField.getName(), PROJECT.getName()));
+    return Sets.union(fs, ImmutableSet.of(LEGACY_ID_STR.getName(), PROJECT.getName()));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 5de3ba4..7029d10 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -22,30 +22,25 @@
 
 /** Definition of account index versions (schemata). See {@link SchemaDefinitions}. */
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
+
   @Deprecated
-  static final Schema<AccountState> V4 =
+  static final Schema<AccountState> V8 =
       schema(
           AccountField.ACTIVE,
           AccountField.EMAIL,
           AccountField.EXTERNAL_ID,
+          AccountField.EXTERNAL_ID_STATE,
           AccountField.FULL_NAME,
           AccountField.ID,
           AccountField.NAME_PART,
+          AccountField.NAME_PART_NO_SECONDARY_EMAIL,
+          AccountField.PREFERRED_EMAIL,
+          AccountField.PREFERRED_EMAIL_EXACT,
+          AccountField.REF_STATE,
           AccountField.REGISTERED,
           AccountField.USERNAME,
           AccountField.WATCHED_PROJECT);
 
-  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
-
-  @Deprecated
-  static final Schema<AccountState> V6 =
-      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
-
-  @Deprecated static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
-
-  @Deprecated
-  static final Schema<AccountState> V8 = schema(V7, AccountField.NAME_PART_NO_SECONDARY_EMAIL);
-
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<AccountState> V9 = schema(V8);
 
@@ -55,14 +50,17 @@
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
   // document id type is replaced with string document id type.
+  @Deprecated
   static final Schema<AccountState> V11 =
       new Schema.Builder<AccountState>()
           .add(V10)
           .remove(AccountField.ID)
           .add(AccountField.ID_STR)
-          .legacyNumericFields(false)
           .build();
 
+  // Bump Lucene version requires reindexing
+  static final Schema<AccountState> V12 = schema(V11);
+
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 50fdcde..81a4d1e 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -100,7 +100,7 @@
       return StalenessCheckResult.notStale();
     }
 
-    boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
+    boolean useLegacyNumericFields = i.getSchema().hasField(AccountField.ID);
     ImmutableSet<String> fields = useLegacyNumericFields ? FIELDS : FIELDS2;
     Optional<FieldBundle> result =
         i.getRaw(
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index c06347e..281bcb4 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -116,9 +116,6 @@
 
   // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      integer("legacy_id").stored().build(cd -> cd.getId().get());
-
   public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
       exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getId().get()));
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 05c5c77..6fc2665 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -30,8 +30,6 @@
 
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
-    return getSchema().useLegacyNumericFields()
-        ? ChangePredicates.id(id)
-        : ChangePredicates.idStr(id);
+    return ChangePredicates.idStr(id);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 8f68904..6849831 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -43,10 +43,8 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
@@ -184,21 +182,6 @@
   }
 
   /**
-   * Start indexing multiple changes in parallel.
-   *
-   * @param ids changes to index.
-   * @return future for completing indexing of all changes.
-   */
-  public ListenableFuture<List<ChangeData>> indexAsync(
-      Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      futures.add(indexAsync(project, id));
-    }
-    return Futures.allAsList(futures);
-  }
-
-  /**
    * Synchronously index a change, then check if the index is stale due to a race condition.
    *
    * @param cd change to index.
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 0a06735..aa08069 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -23,18 +23,25 @@
 /** Definition of change index versions (schemata). See {@link SchemaDefinitions}. */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V55 =
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
+  static final Schema<ChangeData> V74 =
       schema(
           ChangeField.ADDED,
           ChangeField.APPROVAL,
           ChangeField.ASSIGNEE,
+          ChangeField.ATTENTION_SET_FULL,
+          ChangeField.ATTENTION_SET_USERS,
+          ChangeField.ATTENTION_SET_USERS_COUNT,
           ChangeField.AUTHOR,
           ChangeField.CHANGE,
+          ChangeField.CHERRY_PICK,
+          ChangeField.CHERRY_PICK_OF_CHANGE,
+          ChangeField.CHERRY_PICK_OF_PATCHSET,
           ChangeField.COMMENT,
           ChangeField.COMMENTBY,
           ChangeField.COMMIT,
-          ChangeField.COMMITTER,
           ChangeField.COMMIT_MESSAGE,
+          ChangeField.COMMITTER,
           ChangeField.DELETED,
           ChangeField.DELTA,
           ChangeField.DIRECTORY,
@@ -47,14 +54,19 @@
           ChangeField.EXTENSION,
           ChangeField.FILE_PART,
           ChangeField.FOOTER,
+          ChangeField.FUZZY_HASHTAG,
           ChangeField.FUZZY_TOPIC,
           ChangeField.GROUP,
           ChangeField.HASHTAG,
           ChangeField.HASHTAG_CASE_AWARE,
           ChangeField.ID,
+          ChangeField.IS_PURE_REVERT,
+          ChangeField.IS_SUBMITTABLE,
           ChangeField.LABEL,
-          ChangeField.LEGACY_ID,
+          ChangeField.LEGACY_ID_STR,
+          ChangeField.MERGE,
           ChangeField.MERGEABLE,
+          ChangeField.MERGED_ON,
           ChangeField.ONLY_EXTENSIONS,
           ChangeField.OWNER,
           ChangeField.PATCH_SET,
@@ -77,131 +89,18 @@
           ChangeField.STATUS,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
           ChangeField.STORED_SUBMIT_RECORD_STRICT,
+          ChangeField.STORED_SUBMIT_REQUIREMENTS,
           ChangeField.SUBMISSIONID,
           ChangeField.SUBMIT_RECORD,
+          ChangeField.SUBMIT_RULE_RESULT,
           ChangeField.TOTAL_COMMENT_COUNT,
           ChangeField.TR,
           ChangeField.UNRESOLVED_COMMENT_COUNT,
           ChangeField.UPDATED,
+          ChangeField.UPLOADER,
           ChangeField.WIP);
 
   /**
-   * The computation of the {@link ChangeField#EXTENSION} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V56 = schema(V55);
-
-  /**
-   * New numeric types: use dimensional points using the k-d tree geo-spatial data structure to
-   * offer fast single- and multi-dimensional numeric range. As the consequense, {@link
-   * ChangeField#LEGACY_ID} is replaced with {@link ChangeField#LEGACY_ID_STR}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V57 =
-      new Schema.Builder<ChangeData>()
-          .add(V56)
-          .remove(ChangeField.LEGACY_ID)
-          .add(ChangeField.LEGACY_ID_STR)
-          .legacyNumericFields(false)
-          .build();
-
-  /**
-   * Added new fields {@link ChangeField#CHERRY_PICK_OF_CHANGE} and {@link
-   * ChangeField#CHERRY_PICK_OF_PATCHSET}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V58 =
-      new Schema.Builder<ChangeData>()
-          .add(V57)
-          .add(ChangeField.CHERRY_PICK_OF_CHANGE)
-          .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
-          .build();
-
-  /**
-   * Added new fields {@link ChangeField#ATTENTION_SET_USERS} and {@link
-   * ChangeField#ATTENTION_SET_FULL}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V59 =
-      new Schema.Builder<ChangeData>()
-          .add(V58)
-          .add(ChangeField.ATTENTION_SET_USERS)
-          .add(ChangeField.ATTENTION_SET_FULL)
-          .build();
-
-  /** Added new fields {@link ChangeField#MERGE} */
-  @Deprecated
-  static final Schema<ChangeData> V60 =
-      new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
-
-  /** Added new field {@link ChangeField#MERGED_ON} */
-  @Deprecated
-  static final Schema<ChangeData> V61 =
-      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
-
-  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
-  @Deprecated
-  static final Schema<ChangeData> V62 =
-      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
-
-  /**
-   * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V63 = schema(V62, false);
-
-  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
-  @Deprecated static final Schema<ChangeData> V64 = schema(V63, false);
-
-  /** Added new field for submit requirements. */
-  @Deprecated
-  static final Schema<ChangeData> V65 =
-      new Schema.Builder<ChangeData>().add(V64).add(ChangeField.STORED_SUBMIT_REQUIREMENTS).build();
-
-  /**
-   * The computation of {@link ChangeField#LABEL} has changed: We added the non_uploader arg to the
-   * label field.
-   */
-  @Deprecated static final Schema<ChangeData> V66 = schema(V65, false);
-
-  /** Updated submit records: store the rule name that created the submit record. */
-  @Deprecated static final Schema<ChangeData> V67 = schema(V66, false);
-
-  /** Added new field {@link ChangeField#SUBMIT_RULE_RESULT}. */
-  @Deprecated
-  static final Schema<ChangeData> V68 =
-      new Schema.Builder<ChangeData>().add(V67).add(ChangeField.SUBMIT_RULE_RESULT).build();
-
-  /** Added new field {@link ChangeField#CHERRY_PICK}. */
-  @Deprecated
-  static final Schema<ChangeData> V69 =
-      new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
-
-  /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
-  @Deprecated
-  static final Schema<ChangeData> V70 =
-      new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
-
-  /** Added new field {@link ChangeField#UPLOADER}. */
-  @Deprecated
-  static final Schema<ChangeData> V71 =
-      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
-
-  /** Added new field {@link ChangeField#IS_PURE_REVERT}. */
-  @Deprecated
-  static final Schema<ChangeData> V72 =
-      new Schema.Builder<ChangeData>().add(V71).add(ChangeField.IS_PURE_REVERT).build();
-
-  @Deprecated
-  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
-  static final Schema<ChangeData> V73 = schema(V72, false);
-
-  @Deprecated
-  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
-  static final Schema<ChangeData> V74 =
-      new Schema.Builder<ChangeData>().add(V73).add(ChangeField.IS_SUBMITTABLE).build();
-
-  /**
    * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
    * allow easier search for topics.
    */
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 49f6ff9..458f4a4 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -53,7 +53,7 @@
  *
  * <p>Will reindex accounts when the account's NoteDb ref changes.
  */
-public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
+public class ReindexAfterRefUpdate implements GitBatchRefUpdateListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final OneOffRequestContext requestContext;
@@ -86,48 +86,54 @@
   }
 
   @Override
-  public void onGitReferenceUpdated(Event event) {
-    if (allUsersName.get().equals(event.getProjectName())
-        && !RefNames.REFS_CONFIG.equals(event.getRefName())) {
-      Account.Id accountId = Account.Id.fromRef(event.getRefName());
-      if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
-        indexer.get().index(accountId);
+  public void onGitBatchRefUpdate(GitBatchRefUpdateListener.Event event) {
+    if (allUsersName.get().equals(event.getProjectName())) {
+      for (UpdatedRef ref : event.getUpdatedRefs()) {
+        if (!RefNames.REFS_CONFIG.equals(ref.getRefName())) {
+          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+          if (accountId != null && !ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
+            indexer.get().index(accountId);
+            break;
+          }
+        }
       }
       // The update is in All-Users and not on refs/meta/config. So it's not a change. Return early.
       return;
     }
 
-    if (!enabled
-        || event.getRefName().startsWith(RefNames.REFS_CHANGES)
-        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
-        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
-      return;
-    }
-    Futures.addCallback(
-        executor.submit(new GetChanges(event)),
-        new FutureCallback<List<Change>>() {
-          @Override
-          public void onSuccess(List<Change> changes) {
-            for (Change c : changes) {
-              @SuppressWarnings("unused")
-              Future<?> possiblyIgnoredError =
-                  indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+    for (UpdatedRef ref : event.getUpdatedRefs()) {
+      if (!enabled
+          || ref.getRefName().startsWith(RefNames.REFS_CHANGES)
+          || ref.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
+          || ref.getRefName().startsWith(RefNames.REFS_USERS)) {
+        continue;
+      }
+      Futures.addCallback(
+          executor.submit(new GetChanges(event.getProjectName(), ref)),
+          new FutureCallback<List<Change>>() {
+            @Override
+            public void onSuccess(List<Change> changes) {
+              for (Change c : changes) {
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError =
+                    indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+              }
             }
-          }
 
-          @Override
-          public void onFailure(Throwable ignored) {
-            // Logged by {@link GetChanges#call()}.
-          }
-        },
-        directExecutor());
+            @Override
+            public void onFailure(Throwable ignored) {
+              // Logged by {@link GetChanges#call()}.
+            }
+          },
+          directExecutor());
+    }
   }
 
   private abstract class Task<V> implements Callable<V> {
-    protected Event event;
+    protected UpdatedRef updatedRef;
 
-    protected Task(Event event) {
-      this.event = event;
+    protected Task(UpdatedRef updatedRef) {
+      this.updatedRef = updatedRef;
     }
 
     @Override
@@ -135,7 +141,7 @@
       try (ManualRequestContext ctx = requestContext.open()) {
         return impl(ctx);
       } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", event);
+        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", updatedRef);
         throw e;
       }
     }
@@ -146,14 +152,17 @@
   }
 
   private class GetChanges extends Task<List<Change>> {
-    private GetChanges(Event event) {
-      super(event);
+    protected String projectName;
+
+    private GetChanges(String projectName, UpdatedRef updatedRef) {
+      super(updatedRef);
+      this.projectName = projectName;
     }
 
     @Override
     protected List<Change> impl(RequestContext ctx) {
-      String ref = event.getRefName();
-      Project.NameKey project = Project.nameKey(event.getProjectName());
+      String ref = updatedRef.getRefName();
+      Project.NameKey project = Project.nameKey(projectName);
       if (ref.equals(RefNames.REFS_CONFIG)) {
         return asChanges(queryProvider.get().byProjectOpen(project));
       }
@@ -163,9 +172,9 @@
     @Override
     public String toString() {
       return "Get changes to reindex caused by "
-          + event.getRefName()
+          + updatedRef.getRefName()
           + " update of project "
-          + event.getProjectName();
+          + projectName;
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index c4d8952..773aa9a 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -23,23 +23,20 @@
 /** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
-  static final Schema<InternalGroup> V2 =
+  static final Schema<InternalGroup> V5 =
       schema(
+          GroupField.CREATED_ON,
           GroupField.DESCRIPTION,
           GroupField.ID,
           GroupField.IS_VISIBLE_TO_ALL,
+          GroupField.MEMBER,
           GroupField.NAME,
           GroupField.NAME_PART,
           GroupField.OWNER_UUID,
+          GroupField.REF_STATE,
+          GroupField.SUBGROUP,
           GroupField.UUID);
 
-  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
-
-  @Deprecated
-  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
-
-  @Deprecated static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
-
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
 
@@ -48,7 +45,7 @@
 
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range.
-  static final Schema<InternalGroup> V8 = schema(V7, false);
+  static final Schema<InternalGroup> V8 = schema(V7);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 3907da5..eac96a6 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -90,8 +90,9 @@
     return tags.get() == null
         && forceLogging.get() == null
         && performanceLogging.get() == null
+        && (performanceLogRecords.get() == null || performanceLogRecords.get().isEmtpy())
         && aclLogging.get() == null
-        && aclLogRecords.get() == null;
+        && (aclLogRecords.get() == null || aclLogRecords.get().isEmpty());
   }
 
   public void clear() {
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 5cd0e98..b433e9f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -102,6 +102,9 @@
   /** The name of a group. */
   public abstract Optional<String> groupName();
 
+  /** The group system being queried. */
+  public abstract Optional<String> groupSystem();
+
   /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
@@ -328,6 +331,8 @@
 
     public abstract Builder groupName(@Nullable String groupName);
 
+    public abstract Builder groupSystem(@Nullable String groupSystem);
+
     public abstract Builder groupUuid(@Nullable String groupUuid);
 
     public abstract Builder httpStatus(int httpStatus);
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
index baa9b1f..a692d2b 100644
--- a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -45,6 +45,10 @@
     return ImmutableList.copyOf(aclLogRecords);
   }
 
+  public boolean isEmpty() {
+    return aclLogRecords.isEmpty();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
diff --git a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
index 4ee70d7..2965719 100644
--- a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
+++ b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
@@ -46,6 +46,10 @@
     return ImmutableList.copyOf(performanceLogRecords);
   }
 
+  public boolean isEmtpy() {
+    return performanceLogRecords.isEmpty();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index 65e033b15..90e716f 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -56,7 +56,7 @@
     // Do not create performance log entries if performance logging is disabled or if no
     // PerformanceLogger is registered.
     boolean enablePerformanceLogging =
-        gerritConfig.getBoolean("tracing", "performanceLogging", true);
+        gerritConfig.getBoolean("tracing", "performanceLogging", false);
     LoggingContext.getInstance()
         .performanceLogging(
             enablePerformanceLogging && !Iterables.isEmpty(performanceLoggers.entries()));
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0fc89ba..e362c4b 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -366,16 +366,13 @@
       // Send email notifications
       outgoingMailFactory
           .create(
-              ctx.getNotify(notes.getChangeId()),
-              notes,
+              ctx,
               patchSet,
-              ctx.getUser().asIdentifiedUser(),
+              notes.getMetaId(),
               mailMessage,
-              ctx.getWhen(),
               comments,
               patchSetComment,
-              ImmutableList.of(),
-              ctx.getRepoView())
+              ImmutableList.of())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
index b13bcf6..f9ef199 100644
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public AddToAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "addToAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
index 8f898a8..f5af783 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -11,6 +11,7 @@
 // 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.mail.send;
 
 import com.google.gerrit.entities.Account;
@@ -23,8 +24,9 @@
   private Account.Id attentionSetUser;
   private String reason;
 
-  public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
-    super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+  public AttentionSetSender(
+      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
+    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 63b9c70..94b8c72 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.base.Splitter;
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeSizeBucket;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -74,6 +75,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class ChangeEmail extends NotificationEmail {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected static ChangeData newChangeData(
@@ -81,6 +83,11 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  protected static ChangeData newChangeData(
+      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
+  }
+
   private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
@@ -231,6 +238,18 @@
     setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
   }
 
+  private int getInsertionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::insertions)
+        .reduce(0, Integer::sum);
+  }
+
+  private int getDeletionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::deletions)
+        .reduce(0, Integer::sum);
+  }
+
   /** Get a link to the change; null if the server doesn't know its own address. */
   @Nullable
   public String getChangeUrl() {
@@ -285,10 +304,6 @@
                       fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
               .append("\n");
         }
-        Integer insertions =
-            modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
-        Integer deletions =
-            modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
         detail.append(
             MessageFormat.format(
                 "" //
@@ -297,8 +312,8 @@
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
                 modifiedFiles.size() - 1, //
-                insertions, //
-                deletions));
+                getInsertionsCount(), //
+                getDeletionsCount()));
         detail.append("\n");
       }
       return detail.toString();
@@ -309,29 +324,35 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId)
-      throws DiffNotAvailableException {
-    PatchSet ps;
-    if (patchSetId == patchSet.number()) {
-      ps = patchSet;
-    } else {
-      try {
+  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
+    try {
+      PatchSet ps;
+      if (patchSetId == patchSet.number()) {
+        ps = patchSet;
+      } else {
         ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
-      } catch (StorageException e) {
-        throw new DiffNotAvailableException("Failed to get patchSet", e);
       }
+      return args.diffOperations.listModifiedFilesAgainstParent(
+          change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+    } catch (StorageException | DiffNotAvailableException e) {
+      logger.atSevere().withCause(e).log("Failed to get modified files");
+      return new HashMap<>();
     }
-    return args.diffOperations.listModifiedFilesAgainstParent(
-        change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected Map<String, FileDiffOutput> listModifiedFiles() throws DiffNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles() {
     if (patchSet != null) {
-      return args.diffOperations.listModifiedFilesAgainstParent(
-          change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+      try {
+        return args.diffOperations.listModifiedFilesAgainstParent(
+            change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+      } catch (DiffNotAvailableException e) {
+        logger.atSevere().withCause(e).log("Failed to get modified files");
+      }
+    } else {
+      logger.atSevere().log("no patchSet specified");
     }
-    throw new DiffNotAvailableException("no patchSet specified");
+    return new HashMap<>();
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
@@ -411,15 +432,25 @@
 
   @Override
   protected void add(RecipientType rt, Account.Id to) {
-    Optional<AccountState> accountState = args.accountCache.get(to);
-    if (!accountState.isPresent()) {
-      return;
-    }
-    if (emailOnlyAttentionSetIfEnabled
-        && accountState.get().generalPreferences().getEmailStrategy()
-            == EmailStrategy.ATTENTION_SET_ONLY
-        && !currentAttentionSet.contains(to)) {
-      return;
+    addRecipient(rt, to, /* isWatcher= */ false);
+  }
+
+  /** This bypasses the EmailStrategy.ATTENTION_SET_ONLY strategy when adding the recipient. */
+  @Override
+  protected void addWatcher(RecipientType rt, Account.Id to) {
+    addRecipient(rt, to, /* isWatcher= */ true);
+  }
+
+  private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
+    if (!isWatcher) {
+      Optional<AccountState> accountState = args.accountCache.get(to);
+      if (emailOnlyAttentionSetIfEnabled
+          && accountState.isPresent()
+          && accountState.get().generalPreferences().getEmailStrategy()
+              == EmailStrategy.ATTENTION_SET_ONLY
+          && !currentAttentionSet.contains(to)) {
+        return;
+      }
     }
     if (emailOnlyAuthors && !authors.contains(to)) {
       return;
@@ -497,6 +528,9 @@
     changeData.put("ownerName", getNameFor(change.getOwner()));
     changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
+    changeData.put(
+        "sizeBucket",
+        ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
     soyContext.put("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
@@ -530,7 +564,7 @@
       // We need names rather than account ids / emails to make it user readable.
       soyContext.put(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
+          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
     }
   }
 
@@ -579,16 +613,11 @@
   /** Show patch set as unified difference. */
   public String getUnifiedDiff() {
     Map<String, FileDiffOutput> modifiedFiles;
-    try {
-      modifiedFiles = listModifiedFiles();
-      if (modifiedFiles.isEmpty()) {
-        // Octopus merges are not well supported for diff output by Gerrit.
-        // Currently these always have a null oldId in the PatchList.
-        return "[Octopus merge; cannot be formatted as a diff.]\n";
-      }
-    } catch (DiffNotAvailableException e) {
-      logger.atSevere().withCause(e).log("Cannot format patch");
-      return "";
+    modifiedFiles = listModifiedFiles();
+    if (modifiedFiles.isEmpty()) {
+      // Octopus merges are not well supported for diff output by Gerrit.
+      // Currently these always have a null oldId in the PatchList.
+      return "[Empty change (potentially Octopus merge); cannot be formatted as a diff.]\n";
     }
 
     int maxSize = args.settings.maximumDiffSize;
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 81e4f3e..0718b5e 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.FilenameComparator;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -28,6 +33,8 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
@@ -37,7 +44,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
@@ -56,17 +62,25 @@
 import java.util.Optional;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
 public class CommentSender extends ReplyToChangeSender {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id changeId);
+
+    CommentSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private class FileCommentGroup {
+
     public String filename;
     public int patchSetId;
     public PatchFile fileData;
@@ -104,11 +118,14 @@
   }
 
   private List<? extends Comment> inlineComments = Collections.emptyList();
-  private String patchSetComment;
-  private List<LabelVote> labels = Collections.emptyList();
+  @Nullable private String patchSetComment;
+  private ImmutableList<LabelVote> labels = ImmutableList.of();
   private final CommentsUtil commentsUtil;
   private final boolean incomingEmailEnabled;
   private final String replyToAddress;
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public CommentSender(
@@ -116,24 +133,35 @@
       CommentsUtil commentsUtil,
       @GerritServerConfig Config cfg,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      @Assisted Change.Id changeId,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "comment", newChangeData(args, project, changeId));
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
             > Protocol.NONE.ordinal();
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailReviewComments.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
-  public void setPatchSetComment(String comment) {
+  public void setPatchSetComment(@Nullable String comment) {
     this.patchSetComment = comment;
   }
 
-  public void setLabels(List<LabelVote> labels) {
+  public void setLabels(ImmutableList<LabelVote> labels) {
     this.labels = labels;
   }
 
@@ -199,12 +227,7 @@
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
         // Get the modified files:
-        Map<String, FileDiffOutput> modifiedFiles = null;
-        try {
-          modifiedFiles = listModifiedFiles(c.key.patchSetId);
-        } catch (DiffNotAvailableException e) {
-          logger.atSevere().withCause(e).log("Failed to get modified files");
-        }
+        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
 
         groups.add(currentGroup);
         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
@@ -509,6 +532,15 @@
     soyContext.put(
         "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
 
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+
     footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
     footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
     footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
@@ -518,6 +550,59 @@
     }
   }
 
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
+  }
+
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
     try {
       return fileInfo.getLine(side, lineNbr);
@@ -538,8 +623,8 @@
     }
   }
 
-  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
-    List<Map<String, Object>> result = new ArrayList<>();
+  private ImmutableList<Map<String, Object>> getLabelVoteSoyData(ImmutableList<LabelVote> votes) {
+    ImmutableList.Builder<Map<String, Object>> result = ImmutableList.builder();
     for (LabelVote vote : votes) {
       Map<String, Object> data = new HashMap<>();
       data.put("label", vote.label());
@@ -549,7 +634,7 @@
       data.put("value", (int) vote.value());
       result.add(data);
     }
-    return result;
+    return result.build();
   }
 
   private String getCommentTimestamp() {
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index b78dc62..e327d4d 100644
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -14,71 +14,30 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.stream.StreamSupport;
 
 /** Notify interested parties of a brand new change. */
 public class CreateChangeSender extends NewChangeSender {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     CreateChangeSender create(Project.NameKey project, Change.Id changeId);
   }
 
-  private final PermissionBackend permissionBackend;
-
   @Inject
   public CreateChangeSender(
-      EmailArguments args,
-      PermissionBackend permissionBackend,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
     super(args, newChangeData(args, project, changeId));
-    this.permissionBackend = permissionBackend;
   }
 
   @Override
   protected void init() throws EmailException {
     super.init();
 
-    try {
-      // Upgrade watching owners from CC and BCC to TO.
-      Watchers matching =
-          getWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
-      // TODO(hiesel): Remove special handling for owners
-      StreamSupport.stream(matching.all().accounts.spliterator(), false)
-          .filter(this::isOwnerOfProjectOrBranch)
-          .forEach(acc -> add(RecipientType.TO, acc));
-      // Add everyone else. Owners added above will not be duplicated.
-      add(RecipientType.TO, matching.to);
-      add(RecipientType.CC, matching.cc);
-      add(RecipientType.BCC, matching.bcc);
-    } catch (StorageException err) {
-      // Just don't CC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      logger.atWarning().withCause(err).log("Cannot notify watchers for new change");
-    }
-
+    includeWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
   }
-
-  private boolean isOwnerOfProjectOrBranch(Account.Id userId) {
-    return permissionBackend
-        .absentUser(userId)
-        .ref(change.getDest())
-        .testOrFalse(RefPermission.WRITE_CONFIG);
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
index a3cf3e3..25b2ebd 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 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.mail.send;
 
 import static com.google.common.base.Preconditions.checkArgument;
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 04b7972..5b209ce 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -83,13 +83,15 @@
   /** Add users or email addresses to the TO, CC, or BCC list. */
   protected void add(RecipientType type, WatcherList watcherList) {
     for (Account.Id user : watcherList.accounts) {
-      add(type, user);
+      addWatcher(type, user);
     }
     for (Address addr : watcherList.emails) {
       add(type, addr);
     }
   }
 
+  protected abstract void addWatcher(RecipientType type, Account.Id to);
+
   public String getSshHost() {
     String host = Iterables.getFirst(args.sshAddresses, null);
     if (host == null) {
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
index 6762b7d..5242bfb 100644
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public RemoveFromAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "removeFromAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 9516b9f..1b830d9 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,34 +14,88 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id changeId);
+    ReplacePatchSetSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final ChangeKind changeKind;
+  private final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public ReplacePatchSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "newpatchset", newChangeData(args, project, changeId));
+    this.changeKind = changeKind;
+
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailNewPatchSet.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    if (!isChangeNoLongerSubmittable() && changeKind.isTrivialRebase()) {
+      logger.atFine().log(
+          "skip email because new patch set is a trivial rebase that didn't make the change non-submittable");
+      return false;
+    }
+
+    return super.shouldSendMessage();
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -52,6 +106,12 @@
     extraCC.addAll(cc);
   }
 
+  public void addOutdatedApproval(@Nullable Collection<PatchSetApproval> outdatedApprovals) {
+    if (outdatedApprovals != null) {
+      this.outdatedApprovals.addAll(outdatedApprovals);
+    }
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -82,7 +142,7 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  public ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       if (id.equals(fromId)) {
@@ -93,12 +153,87 @@
     if (names.isEmpty()) {
       return null;
     }
-    return names;
+    return names.stream().sorted().collect(toImmutableList());
+  }
+
+  private ImmutableList<String> formatOutdatedApprovals() {
+    return outdatedApprovals.stream()
+        .map(
+            outdatedApproval ->
+                String.format(
+                    "%s by %s",
+                    LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
+                    getNameFor(outdatedApproval.accountId())))
+        .sorted()
+        .collect(toImmutableList());
   }
 
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("outdatedApprovals", formatOutdatedApprovals());
+
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+  }
+
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 0a721cf..c06cc1e 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -385,7 +385,7 @@
     try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
       qp.write(input.getBytes(UTF_8));
     }
-    return s.toString();
+    return s.toString(UTF_8);
   }
 
   private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 5d19205..73161d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -92,6 +92,7 @@
   private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeDraftUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 37a38fe..69185b1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -16,6 +16,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
 import com.google.gson.Gson;
@@ -63,6 +65,9 @@
             new OptionalBooleanAdapter().nullSafe())
         .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
         .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
+        .registerTypeAdapter(
+            SubmitRequirementExpressionResult.Status.class,
+            new SubmitRequirementExpressionResultStatusAdapter())
         .setPrettyPrinting()
         .create();
   }
@@ -156,4 +161,32 @@
       return builder.build();
     }
   }
+
+  /**
+   * A Json type adapter for the Status enum in {@link SubmitRequirementExpressionResult}. This
+   * adapter is able to parse unrecognized values. Unrecognized values are converted to the value
+   * "ERROR" The adapter is needed to ensure forward compatibility since we want to add more values
+   * to this enum. We do that to ensure safer rollout in distributed setups where some tasks are
+   * updated before others. We make sure that tasks running the old binaries are still able to parse
+   * values written by tasks running the new binaries.
+   *
+   * <p>TODO(ghareeb): Remove this adapter.
+   */
+  static class SubmitRequirementExpressionResultStatusAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult.Status> {
+    @Override
+    public void write(JsonWriter jsonWriter, Status status) throws IOException {
+      jsonWriter.value(status.name());
+    }
+
+    @Override
+    public Status read(JsonReader jsonReader) throws IOException {
+      String val = jsonReader.nextString();
+      try {
+        return SubmitRequirementExpressionResult.Status.valueOf(val);
+      } catch (IllegalArgumentException e) {
+        return SubmitRequirementExpressionResult.Status.ERROR;
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index ca636e8..0e5cf11 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
@@ -47,6 +46,7 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApprovals;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.git.RepoRefCache;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -390,8 +391,7 @@
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsWithCopied;
+  private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
 
   public ChangeNotes(
@@ -429,28 +429,14 @@
     return patchSets;
   }
 
-  /**
-   * Gets the approvals, not including the copied approvals. To get copied approvals as well, use
-   * {@link #getApprovalsWithCopied}, or use {@code ApprovalInference}.
-   */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+  /** Gets the approvals of all patch sets. */
+  public PatchSetApprovals getApprovals() {
     if (approvals == null) {
-      approvals =
-          state.approvals().stream()
-              .filter(e -> !e.getValue().copied())
-              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
+      approvals = PatchSetApprovals.create(ImmutableListMultimap.copyOf(state.approvals()));
     }
     return approvals;
   }
 
-  /** Gets all approvals, including copied approvals. */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
-    if (approvalsWithCopied == null) {
-      approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
-    }
-    return approvalsWithCopied;
-  }
-
   public ReviewerSet getReviewers() {
     return state.reviewers();
   }
@@ -702,6 +688,10 @@
 
   @Override
   protected ObjectId readRef(Repository repo) throws IOException {
-    return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
+    Optional<RefCache> refsCache =
+        Optional.ofNullable(refs).map(Optional::of).orElse(RepoRefCache.getOptional(repo));
+    return refsCache.isPresent()
+        ? refsCache.get().get(getRefName()).orElse(null)
+        : super.readRef(repo);
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index a4623d6..85bb445 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -181,6 +181,7 @@
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
   private List<SubmitRequirementResult> submitRequirementResults;
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -426,12 +427,21 @@
   }
 
   /**
-   * All updates must have a timestamp of null since we use the commit's timestamp. There also must
-   * not be multiple updates for a single user. Only the first update takes place because of the
-   * different priorities: e.g, if we want to add someone to the attention set but also want to
-   * remove someone from the attention set, we should ensure to add/remove that user based on the
-   * priority of the addition and removal. If most importantly we want to remove the user, then we
-   * must first create the removal, and the addition will not take effect.
+   * Adds attention set updates that should be stored in NoteDb.
+   *
+   * <p>If invoked multiple times with attention set updates for the same user, only the attention
+   * set update of the first invocation is stored for this user and further attention set updates
+   * for this user are silently ignored. This means if callers invoke this method multiple times
+   * with attention set updates for the same user, they must ensure that the first call is being
+   * done with the attention set update that should take precedence.
+   *
+   * @param updates Attention set updates that should be performed. The updates must not have any
+   *     timestamp set ({@link AttentionSetUpdate#timestamp()} must return {@code null}). This is
+   *     because the timestamp of all performed updates is always the timestamp of when the NoteDb
+   *     commit is created. Each of the provided updates must be for a different user, if there are
+   *     multiple updates for the same user the update is rejected.
+   * @throws IllegalArgumentException thrown if any of the provided updates has a timestamp set, or
+   *     if the provided set of updates contains multiple updates for the same user
    */
   public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
     if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
@@ -542,6 +552,13 @@
             Iterables.getLast(getNotes().getPatchSets().values()).commitId();
         cache.get(latestPsCommitId).createEmptySubmitRequirementResults();
       } else {
+        // Clear any previously stored SRs first. The SRs in this update will overwrite any
+        // previously stored SRs (e.g. if the change is abandoned (SRs stored) -> un-abandoned ->
+        // merged).
+        submitRequirementResults.stream()
+            .map(SubmitRequirementResult::patchSetCommitId)
+            .distinct()
+            .forEach(commit -> cache.get(commit).clearSubmitRequirementResults());
         for (SubmitRequirementResult sr : submitRequirementResults) {
           cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
         }
@@ -813,7 +830,10 @@
       }
     }
 
-    updateAttentionSet(msg);
+    boolean hasAttentionSeUpdates = updateAttentionSet(msg);
+    if (isEmptyWithoutAttentionSet() && !hasAttentionSeUpdates) {
+      return NO_OP_UPDATE;
+    }
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -903,11 +923,13 @@
       // be submitted or when the caller is a robot.
       return;
     }
+
+    Set<AttentionSetUpdate> updates = new HashSet<>();
     Set<Account.Id> currentReviewers =
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
-    Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
       Account.Id reviewerId = reviewer.getKey();
+
       ReviewerStateInternal reviewerState = reviewer.getValue();
       // Only add new reviewers to the attention set. Also, don't add the owner because the owner
       // can only be a "dummy" reviewer for legacy reasons.
@@ -947,8 +969,11 @@
    * <p>Changing the behaviour of this method might affect the way a ChangeUpdate is considered to
    * be an "Attention Set Change Only". Make sure the {@link #isAttentionSetChangeOnly} logic is
    * amended as well if needed.
+   *
+   * @return True if one or more attention set updates are appended to the {@code msg}, and false
+   *     otherwise.
    */
-  private void updateAttentionSet(StringBuilder msg) {
+  private boolean updateAttentionSet(StringBuilder msg) {
     if (plannedAttentionSetUpdates == null) {
       plannedAttentionSetUpdates = new HashMap<>();
     }
@@ -974,6 +999,8 @@
 
     removeInactiveUsersFromAttentionSet(currentReviewers);
 
+    boolean hasUpdates = false;
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -1008,7 +1035,9 @@
       }
 
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      hasUpdates = true;
     }
+    return hasUpdates;
   }
 
   private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
@@ -1076,6 +1105,10 @@
 
   @Override
   public boolean isEmpty() {
+    return isEmptyWithoutAttentionSet() && plannedAttentionSetUpdates == null;
+  }
+
+  private boolean isEmptyWithoutAttentionSet() {
     return commitSubject == null
         && approvals.isEmpty()
         && copiedApprovals.isEmpty()
@@ -1088,7 +1121,6 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && plannedAttentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 534da0d..e7a8948 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
@@ -355,7 +356,7 @@
   private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
     Set<Account.Id> accounts = new HashSet<>();
     accounts.add(changeNotes.getChange().getOwner());
-    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().all().values()) {
       if (patchSetApproval.accountId() != null) {
         accounts.add(patchSetApproval.accountId());
       }
@@ -1251,7 +1252,7 @@
       fmt.setContext(0);
       fmt.format(diff, oldBody, newBody);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index ac9fa48..35a014c 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -90,6 +90,7 @@
       put = Maps.newHashMapWithExpectedSize(baseComments.size());
       if (base instanceof ChangeRevisionNote) {
         pushCert = ((ChangeRevisionNote) base).getPushCert();
+        submitRequirementResults = ((ChangeRevisionNote) base).getSubmitRequirementsResult();
       }
     } else {
       baseRaw = new byte[0];
@@ -123,6 +124,10 @@
     submitRequirementResults = new ArrayList<>();
   }
 
+  void clearSubmitRequirementResults() {
+    submitRequirementResults = null;
+  }
+
   void putSubmitRequirementResult(SubmitRequirementResult result) {
     if (submitRequirementResults == null) {
       submitRequirementResults = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index edf5bd3..7f067f5 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -69,6 +69,7 @@
 
   private List<RobotComment> put = new ArrayList<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -81,6 +82,7 @@
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
   }
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index fa27657..f29d1c1 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -174,17 +174,42 @@
       return Optional.empty();
     }
 
+    return Optional.of(
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            createAutoMergeCommit(repoView, rw, ins, maybeMergeCommit),
+            RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+  }
+
+  /**
+   * Creates an auto merge commit for the provided merge commit.
+   *
+   * <p>Callers are expected to ensure that the provided commit indeed has 2 parents.
+   *
+   * @return An auto-merge commit. Headers of the returned RevCommit are parsed.
+   */
+  ObjectId createAutoMergeCommit(
+      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit mergeCommit) throws IOException {
     ObjectId autoMerge;
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
           createAutoMergeCommit(
-              repoView.getConfig(), rw, ins, maybeMergeCommit, configuredMergeStrategy);
+              repoView.getConfig(), rw, ins, mergeCommit, configuredMergeStrategy);
     }
     counter.increment(OperationType.ON_DISK_WRITE);
     logger.atFine().log("Added %s AutoMerge ref update for commit", autoMerge.name());
-    return Optional.of(
-        new ReceiveCommand(
-            ObjectId.zeroId(), autoMerge, RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+    return autoMerge;
+  }
+
+  Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName) throws IOException {
+    Ref ref = repo.getRefDatabase().exactRef(refName);
+    if (ref != null && ref.getObjectId() != null) {
+      RevObject obj = rw.parseAny(ref.getObjectId());
+      if (obj instanceof RevCommit) {
+        return Optional.of((RevCommit) obj);
+      }
+    }
+    return Optional.empty();
   }
 
   /**
@@ -255,18 +280,6 @@
     return rw.parseCommit(ins.insert(cb));
   }
 
-  private Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName)
-      throws IOException {
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      RevObject obj = rw.parseAny(ref.getObjectId());
-      if (obj instanceof RevCommit) {
-        return Optional.of((RevCommit) obj);
-      }
-    }
-    return Optional.empty();
-  }
-
   private static class NonFlushingWrapper extends ObjectInserter.Filter {
     private final ObjectInserter ins;
 
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index ed68dfd..56a01b9 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,19 +30,15 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** A utility class for computing the base commit / parent for a specific patchset commit. */
 @Singleton
 class BaseCommitUtil {
   private final AutoMerger autoMerger;
-  private final ThreeWayMergeStrategy mergeStrategy;
   private final GitRepositoryManager repoManager;
 
   /** If true, auto-merge results are stored in the repository. */
@@ -52,7 +47,6 @@
   @Inject
   BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
     this.autoMerger = am;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
     this.saveAutomerge = AutoMerger.cacheAutomerge(cfg);
     this.repoManager = repoManager;
   }
@@ -123,69 +117,29 @@
                 "diff against auto-merge commits is only supported if 'change.cacheAutomerge' config is set to true.");
           }
           // TODO(ghareeb): Avoid persisting auto-merge commits.
-          RevCommit autoMerge = createAutoMergeInGitIfNecessary(repo, ins, rw, current);
-          return autoMerge == null ? getAutoMergeFromGit(repo, current) : autoMerge;
+          return getAutoMergeFromGitOrCreate(repo, ins, rw, current);
         }
         return null;
     }
   }
 
   /**
-   * Creates the auto-merge commit in git. If the auto-merge already exists, this does nothing.
-   * Otherwise, the auto-merge is created, persisted in git and the cache-automerge ref is updated
-   * for the merge commit.
+   * Gets the auto-merge commit from git if it already exists. If not, the auto-merge is created,
+   * persisted in git and the cache-automerge ref is updated for the merge commit.
    *
-   * @return null if the auto-merge already exists in git, or the auto-merge {@link RevCommit}
-   *     object otherwise.
+   * @return the auto-merge {@link RevCommit}
    */
-  private RevCommit createAutoMergeInGitIfNecessary(
+  private RevCommit getAutoMergeFromGitOrCreate(
       Repository repo, ObjectInserter ins, RevWalk rw, RevCommit mergeCommit) throws IOException {
-    Optional<ReceiveCommand> receive =
-        autoMerger.createAutoMergeCommitIfNecessary(
-            new RepoView(repo, rw, ins), rw, ins, mergeCommit);
-    if (receive.isPresent()) {
-      ins.flush();
-      return updateRef(repo, rw, receive.get().getRefName(), receive.get().getNewId(), mergeCommit);
+    String refName = RefNames.refsCacheAutomerge(mergeCommit.name());
+    Optional<RevCommit> autoMergeCommit = autoMerger.lookupCommit(repo, rw, refName);
+    if (autoMergeCommit.isPresent()) {
+      return autoMergeCommit.get();
     }
-    return null;
-  }
-
-  private RevCommit getAutoMergeFromGit(Repository repo, RevCommit mergeCommit) throws IOException {
-    try (InMemoryInserter inMemoryIns = new InMemoryInserter(repo);
-        RevWalk inMemoryRw = new RevWalk(inMemoryIns.newReader())) {
-      return autoMerger.lookupFromGitOrMergeInMemory(
-          repo, inMemoryRw, inMemoryIns, mergeCommit, mergeStrategy);
-    }
-  }
-
-  private static RevCommit updateRef(
-      Repository repo, RevWalk rw, String refName, ObjectId autoMergeId, RevCommit merge)
-      throws IOException {
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setNewObjectId(autoMergeId);
-    ru.disableRefLog();
-    switch (ru.update()) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        return rw.parseCommit(autoMergeId);
-      case LOCK_FAILURE:
-        throw new LockFailureException(
-            String.format("Failed to create auto-merge of %s", merge.name()), ru);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      case RENAMED:
-      default:
-        throw new IOException(
-            String.format(
-                "Failed to create auto-merge of %s: Cannot write %s (%s)",
-                merge.name(), refName, ru.getResult()));
-    }
+    ObjectId autoMergeId =
+        autoMerger.createAutoMergeCommit(new RepoView(repo, rw, ins), rw, ins, mergeCommit);
+    ins.flush();
+    return rw.parseCommit(autoMergeId);
   }
 
   private ObjectInserter newInserter(Repository repo) {
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
index ea92a99..5bd8fd4 100644
--- a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch;
 
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index a265337..a53660a 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch;
 
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index a5b4d0a..86f122e 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch;
 
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 2385a70..7562b49 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -294,7 +294,7 @@
     ProjectState projectState =
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
     PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
-    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().onlyNonCopied().values()) {
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
index 76d1710..28c57cb 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.diff;
 
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index 63dd63d..9954d96 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.diff;
 
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
index 512da6f..c569de8 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.diff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/Edit.java b/java/com/google/gerrit/server/patch/filediff/Edit.java
index 4a698a4..a22857f 100644
--- a/java/com/google/gerrit/server/patch/filediff/Edit.java
+++ b/java/com/google/gerrit/server/patch/filediff/Edit.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
index a9bcf03..df8bc09 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 92c3b39..3e4e72d 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 242c1a4..31fe77a 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
index 8eda234..b92ba8e 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
index a009a02..eae78aa 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileEdits.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
index 3720680..74ac18f 100644
--- a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
+++ b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
index d178f22..676b55f 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
index b7144d7..51a592d 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
index a678379..dfe4cf8 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index 3596a54..a464235 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
index 7454f81..5b1e343 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 2f23c8c..22ec328 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
index 2516761..adaeb90 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 0888f3f..2b856fb 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitfilediff;
 
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.util.git.CloseablePool;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -68,7 +69,6 @@
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.patch.FileHeader;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
@@ -207,8 +207,7 @@
                 .collect(Collectors.groupingBy(GitFileDiffCacheKey::project));
 
         for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
-          try (Repository repo = repoManager.openRepository(entry.getKey());
-              ObjectReader reader = repo.newObjectReader()) {
+          try (Repository repo = repoManager.openRepository(entry.getKey())) {
 
             // Grouping keys by diff options because each group of keys will be processed with a
             // separate call to JGit using the DiffFormatter object.
@@ -217,7 +216,7 @@
 
             for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group :
                 optionsGroups.entrySet()) {
-              result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+              result.putAll(loadAllImpl(repo, group.getKey(), group.getValue()));
             }
           }
         }
@@ -232,42 +231,46 @@
      * @return The git file diffs for all input keys.
      */
     private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
-        Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
+        Repository repo, DiffOptions options, List<GitFileDiffCacheKey> keys)
         throws IOException, DiffNotAvailableException {
       ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
           ImmutableMap.builderWithExpectedSize(keys.size());
       Map<GitFileDiffCacheKey, String> filePaths =
           keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
-      DiffFormatter formatter = createDiffFormatter(options, repo, reader);
-      ListMultimap<String, DiffEntry> diffEntries =
-          loadDiffEntries(formatter, options, filePaths.values());
-      for (GitFileDiffCacheKey key : filePaths.keySet()) {
-        String newFilePath = filePaths.get(key);
-        if (!diffEntries.containsKey(newFilePath)) {
-          result.put(
-              key,
-              GitFileDiff.empty(
-                  AbbreviatedObjectId.fromObjectId(key.oldTree()),
-                  AbbreviatedObjectId.fromObjectId(key.newTree()),
-                  newFilePath));
-          continue;
+      try (CloseablePool<DiffFormatter> diffPool =
+          new CloseablePool<>(() -> createDiffFormatter(options, repo))) {
+        ListMultimap<String, DiffEntry> diffEntries;
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          diffEntries = loadDiffEntries(formatter.get(), options, filePaths.values());
         }
-        List<DiffEntry> entries = diffEntries.get(newFilePath);
-        if (entries.size() == 1) {
-          result.put(key, createGitFileDiff(entries.get(0), formatter, key));
-        } else {
-          // Handle when JGit returns two {Added, Deleted} entries for the same file. This happens,
-          // for example, when a file's mode is changed between patchsets (e.g. converting a
-          // symlink to a regular file). We combine both diff entries into a single entry with
-          // {changeType = Rewrite}.
-          List<GitFileDiff> gitDiffs = new ArrayList<>();
-          for (DiffEntry entry : diffEntries.get(newFilePath)) {
-            gitDiffs.add(createGitFileDiff(entry, formatter, key));
+        for (GitFileDiffCacheKey key : filePaths.keySet()) {
+          String newFilePath = filePaths.get(key);
+          if (!diffEntries.containsKey(newFilePath)) {
+            result.put(
+                key,
+                GitFileDiff.empty(
+                    AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                    AbbreviatedObjectId.fromObjectId(key.newTree()),
+                    newFilePath));
+            continue;
           }
-          result.put(key, createRewriteEntry(gitDiffs));
+          List<DiffEntry> entries = diffEntries.get(newFilePath);
+          if (entries.size() == 1) {
+            result.put(key, createGitFileDiff(entries.get(0), key, diffPool));
+          } else {
+            // Handle when JGit returns two {Added, Deleted} entries for the same file. This
+            // happens, for example, when a file's mode is changed between patchsets (e.g.
+            // converting a symlink to a regular file). We combine both diff entries into a single
+            // entry with {changeType = Rewrite}.
+            List<GitFileDiff> gitDiffs = new ArrayList<>();
+            for (DiffEntry entry : diffEntries.get(newFilePath)) {
+              gitDiffs.add(createGitFileDiff(entry, key, diffPool));
+            }
+            result.put(key, createRewriteEntry(gitDiffs));
+          }
         }
+        return result.build();
       }
-      return result.build();
     }
 
     private static ListMultimap<String, DiffEntry> loadDiffEntries(
@@ -288,10 +291,9 @@
                   MultimapBuilder.treeKeys().arrayListValues()::build));
     }
 
-    private static DiffFormatter createDiffFormatter(
-        DiffOptions diffOptions, Repository repo, ObjectReader reader) {
+    private static DiffFormatter createDiffFormatter(DiffOptions diffOptions, Repository repo) {
       try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(reader, repo.getConfig());
+        diffFormatter.setRepository(repo);
         RawTextComparator cmp = comparatorFor(diffOptions.whitespace());
         diffFormatter.setDiffComparator(cmp);
         if (diffOptions.renameScore() != -1) {
@@ -334,25 +336,31 @@
      *       timeout enforcement.
      */
     private GitFileDiff createGitFileDiff(
-        DiffEntry diffEntry, DiffFormatter formatter, GitFileDiffCacheKey key) throws IOException {
+        DiffEntry diffEntry, GitFileDiffCacheKey key, CloseablePool<DiffFormatter> diffPool)
+        throws IOException {
       if (!key.useTimeout()) {
-        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
-        return GitFileDiff.create(diffEntry, fileHeader);
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          FileHeader fileHeader = formatter.get().toFileHeader(diffEntry);
+          return GitFileDiff.create(diffEntry, fileHeader);
+        }
       }
-      Future<FileHeader> fileHeaderFuture =
+      // This submits the DiffFormatter to a different thread. The CloseablePool and our usage of it
+      // ensures that any DiffFormatter instance and the ObjectReader it references internally is
+      // only used by a single thread concurrently. However, ObjectReaders have a reference to
+      // Repository which might not be thread safe (FileRepository is, DfsRepository might not).
+      // This could lead to a race condition.
+      Future<GitFileDiff> fileDiffFuture =
           diffExecutor.submit(
               () -> {
-                synchronized (diffEntry) {
-                  return formatter.toFileHeader(diffEntry);
+                try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+                  return GitFileDiff.create(diffEntry, formatter.get().toFileHeader(diffEntry));
                 }
               });
       try {
         // We employ the timeout because of a bug in Myers diff in JGit. See
         // bugs.chromium.org/p/gerrit/issues/detail?id=487 for more details. The bug may happen
         // if the algorithm used in diffs is HISTOGRAM_WITH_FALLBACK_MYERS.
-        fileHeaderFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
-        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
-        return GitFileDiff.create(diffEntry, fileHeader);
+        return fileDiffFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
       } catch (InterruptedException | TimeoutException e) {
         // If timeout happens, create a negative result
         metrics.timeouts.increment();
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
index 47f7791..7651517 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.patch.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 66299a8..aa49852 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -124,11 +123,12 @@
     @Override
     public ForProject project(Project.NameKey project) {
       try {
-        ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
         ProjectControl control =
             PerThreadCache.getOrCompute(
                 PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
-                () -> projectControlFactory.create(user, state));
+                () ->
+                    projectControlFactory.create(
+                        user, projectCache.get(project).orElseThrow(illegalState(project))));
         return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index d2e85be..3f84dff 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,7 +31,6 @@
       // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
       factory(DefaultRefFilter.Factory.class);
-      factory(VisibleChangesCache.Factory.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 4f10528..3c76e04 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,18 +15,20 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toCollection;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -40,12 +42,14 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Objects;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -60,38 +64,39 @@
   }
 
   private final TagCache tagCache;
-  private final ChangeNotes.Factory changeNotesFactory;
   private final PermissionBackend permissionBackend;
   private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
   private final CurrentUser user;
   private final ProjectState projectState;
   private final PermissionBackend.ForProject permissionBackendForProject;
+  private final @Nullable SearchingChangeCacheImpl searchingChangeDataProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
-  private final VisibleChangesCache.Factory visibleChangesCacheFactory;
-
-  private VisibleChangesCache visibleChangesCache;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
-      ChangeNotes.Factory changeNotesFactory,
       PermissionBackend permissionBackend,
       RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
-      VisibleChangesCache.Factory visibleChangesCacheFactory,
+      @Nullable SearchingChangeCacheImpl searchingChangeDataProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory changeNotesFactory,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
-    this.changeNotesFactory = changeNotesFactory;
     this.permissionBackend = permissionBackend;
     this.refVisibilityControl = refVisibilityControl;
+    this.searchingChangeDataProvider = searchingChangeDataProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeNotesFactory = changeNotesFactory;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
-    this.visibleChangesCacheFactory = visibleChangesCacheFactory;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
@@ -113,7 +118,6 @@
   /** Filters given refs and tags by visibility. */
   ImmutableList<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
-    visibleChangesCache = visibleChangesCacheFactory.create(projectControl, repo);
     logger.atFinest().log(
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
@@ -126,33 +130,24 @@
         "Project state %s permits read = %s",
         projectState.getProject().getState(), projectState.statePermitsRead());
 
-    // See if we can get away with a single, cheap ref evaluation.
-    if (refs.size() == 1) {
-      String refName = Iterables.getOnlyElement(refs).getName();
-      if (opts.filterMeta() && isMetadata(refName)) {
-        logger.atFinest().log("Filter out metadata ref %s", refName);
-        return ImmutableList.of();
-      }
-      if (RefNames.isRefsChanges(refName)) {
-        boolean isChangeRefVisisble = canSeeSingleChangeRef(repo, refName);
-        if (isChangeRefVisisble) {
-          logger.atFinest().log("Change ref %s is visible", refName);
-          return ImmutableList.copyOf(refs);
-        }
-        logger.atFinest().log("Filter out non-visible change ref %s", refName);
-        return ImmutableList.of();
-      }
-    }
-
     // Perform an initial ref filtering with all the refs the caller asked for. If we find tags that
     // we have to investigate separately (deferred tags) then perform a reachability check starting
     // from all visible branches (refs/heads/*).
-    Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts);
+    ImmutableMap<Change.Id, ChangeData> visibleChanges =
+        GitVisibleChangeFilter.getVisibleChanges(
+            searchingChangeDataProvider,
+            changeNotesFactory,
+            changeDataFactory,
+            projectState.getNameKey(),
+            permissionBackendForProject,
+            repo,
+            changes(refs));
+    Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts, visibleChanges);
     ImmutableList.Builder<Ref> visibleRefs = ImmutableList.builder();
     visibleRefs.addAll(initialRefFilter.visibleRefs());
     if (!initialRefFilter.deferredTags().isEmpty()) {
       try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
-        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts);
+        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts, visibleChanges);
         checkState(
             allVisibleBranches.deferredTags().isEmpty(),
             "unexpected tags found when filtering refs/heads/* "
@@ -187,15 +182,20 @@
    * separately for later rev-walk-based visibility computation. Tags where visibility is trivial to
    * compute will be returned as part of {@link Result#visibleRefs()}.
    */
-  Result filterRefs(List<Ref> refs, RefFilterOptions opts) throws PermissionBackendException {
+  Result filterRefs(
+      List<Ref> refs, RefFilterOptions opts, ImmutableMap<Change.Id, ChangeData> visibleChanges)
+      throws PermissionBackendException {
     logger.atFinest().log("Filter refs (refs = %s)", refs);
+    if (!projectState.statePermitsRead()) {
+      return new AutoValue_DefaultRefFilter_Result(ImmutableList.of(), ImmutableList.of());
+    }
 
     // TODO(hiesel): Remove when optimization is done.
     boolean hasReadOnRefsStar =
         checkProjectPermission(permissionBackendForProject, ProjectPermission.READ);
     logger.atFinest().log("User has READ on refs/* = %s", hasReadOnRefsStar);
     if (skipFullRefEvaluationIfAllRefsAreVisible && !projectState.isAllUsers()) {
-      if (projectState.statePermitsRead() && hasReadOnRefsStar) {
+      if (hasReadOnRefsStar) {
         skipFilterCount.increment();
         logger.atFinest().log(
             "Fast path, all refs are visible because user has READ on refs/*: %s", refs);
@@ -254,9 +254,9 @@
         // most recent changes).
         if (hasAccessDatabase) {
           resultRefs.add(ref);
-        } else if (!visibleChangesCache.isVisible(changeId)) {
+        } else if (!visibleChanges.containsKey(changeId)) {
           logger.atFinest().log("Filter out invisible change ref %s", refName);
-        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName)) {
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName, visibleChanges)) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
@@ -294,6 +294,19 @@
     }
   }
 
+  /**
+   * Returns the number of changes contained in {@code refs}. A change has one meta ref and many
+   * patch set refs. We count over the meta refs to make sure we get the number of unique changes in
+   * the provided refs.
+   */
+  private static ImmutableSet<Change.Id> changes(Collection<Ref> refs) {
+    return refs.stream()
+        .map(Ref::getName)
+        .map(Change.Id::fromRef)
+        .filter(Objects::nonNull)
+        .collect(toImmutableSet());
+  }
+
   private List<Ref> fastHideRefsMetaConfig(List<Ref> refs) throws PermissionBackendException {
     if (!canReadRef(REFS_CONFIG)) {
       return refs.stream()
@@ -303,7 +316,8 @@
     return refs;
   }
 
-  private boolean visibleEdit(String name) throws PermissionBackendException {
+  private boolean visibleEdit(String name, ImmutableMap<Change.Id, ChangeData> visibleChanges)
+      throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
     if (id == null) {
       logger.atWarning().log("Couldn't extract change ID from edit ref %s", name);
@@ -312,17 +326,16 @@
 
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
-        && visibleChangesCache.isVisible(id)) {
+        && visibleChanges.containsKey(id)) {
       logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
 
-    if (visibleChangesCache.isVisible(id)) {
+    if (visibleChanges.containsKey(id)) {
       // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+      BranchNameKey dest = visibleChanges.get(id).change().getDest();
       boolean canRead =
-          permissionBackendForProject
-              .ref(visibleChangesCache.getBranchNameKey(id).branch())
-              .test(RefPermission.READ_PRIVATE_CHANGES);
+          permissionBackendForProject.ref(dest.branch()).test(RefPermission.READ_PRIVATE_CHANGES);
       logger.atFinest().log(
           "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", name);
       return canRead;
@@ -343,8 +356,7 @@
   }
 
   private boolean canReadRef(String ref) throws PermissionBackendException {
-    return permissionBackendForProject.ref(ref).test(RefPermission.READ)
-        && projectState.statePermitsRead();
+    return permissionBackendForProject.ref(ref).test(RefPermission.READ);
   }
 
   private boolean checkProjectPermission(
@@ -353,37 +365,6 @@
     return forProject.test(perm);
   }
 
-  /**
-   * Returns true if the user can see the provided change ref. Uses NoteDb for evaluation, hence
-   * does not suffer from the limitations documented in {@link SearchingChangeCacheImpl}.
-   *
-   * <p>This code lets users fetch changes that are not among the fraction of most recently modified
-   * changes that {@link SearchingChangeCacheImpl} returns. This works only when Git Protocol v2
-   * with refs-in-wants is used as that enables Gerrit to skip traditional advertisement of all
-   * visible refs.
-   */
-  private boolean canSeeSingleChangeRef(Repository repo, String refName)
-      throws PermissionBackendException {
-    // We are treating just a single change ref. We are therefore not going through regular ref
-    // filtering, but use NoteDb directly. This makes it so that we can always serve this ref
-    // even if the change is not part of the set of most recent changes that
-    // SearchingChangeCacheImpl returns.
-    Change.Id cId = Change.Id.fromRef(refName);
-    if (cId == null) {
-      // The ref is not a valid change ref. Treat it as non-visible since it's not representing a
-      // change.
-      logger.atWarning().log("invalid change ref %s is not visible", refName);
-      return false;
-    }
-    ChangeNotes notes;
-    try {
-      notes = changeNotesFactory.create(repo, projectState.getNameKey(), cId);
-    } catch (StorageException e) {
-      throw new PermissionBackendException("can't construct change notes", e);
-    }
-    return permissionBackendForProject.change(notes).test(ChangePermission.READ);
-  }
-
   @AutoValue
   abstract static class Result {
     /** Subset of the refs passed into the computation that is visible to the user. */
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
new file mode 100644
index 0000000..12a7841
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -0,0 +1,144 @@
+// 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.permissions;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class can tell efficiently if changes are visible to a user. It is intended to be used when
+ * serving Git traffic on the Git wire protocol and in similar use cases when we need to know
+ * efficiently if a (potentially large number) of changes are visible to a user.
+ *
+ * <p>The efficiency of this class comes from heuristic optimization:
+ *
+ * <ul>
+ *   <li>For a low number of expected checks, we check visibility one-by-one.
+ *   <li>For a high number of expected checks and settings where the change index is available, we
+ *       load the N most recent changes from the index and filter them by visibility. This is fast,
+ *       but comes with the caveat that older changes are pretended to be invisible.
+ *   <li>For a high number of expected checks and settings where the change index is unavailable, we
+ *       scan the repo and determine visibility one-by-one. This is *very* expensive.
+ * </ul>
+ *
+ * <p>Changes that fail to load are pretended to be invisible. This is important on the Git paths as
+ * we don't want to advertise change refs where we were unable to check the visibility (e.g. due to
+ * data corruption on that change). At the same time, the overall operation should succeed as
+ * otherwise a single broken change would break Git operations for an entire repo.
+ */
+public class GitVisibleChangeFilter {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int CHANGE_LIMIT_FOR_DIRECT_FILTERING = 5;
+
+  private GitVisibleChangeFilter() {}
+
+  /** Returns a map of all visible changes. Might pretend old changes are invisible. */
+  static ImmutableMap<Change.Id, ChangeData> getVisibleChanges(
+      @Nullable SearchingChangeCacheImpl searchingChangeCache,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      Project.NameKey projectName,
+      PermissionBackend.ForProject forProject,
+      Repository repository,
+      ImmutableSet<Change.Id> changes) {
+    Stream<ChangeData> changeDatas;
+    if (changes.size() < CHANGE_LIMIT_FOR_DIRECT_FILTERING) {
+      changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName);
+    } else if (searchingChangeCache != null) {
+      changeDatas = searchingChangeCache.getChangeData(projectName);
+    } else {
+      changeDatas =
+          scanRepoForChangeDatas(changeNotesFactory, changeDataFactory, repository, projectName);
+    }
+
+    return changeDatas
+        .filter(cd -> changes.contains(cd.getId()))
+        .filter(
+            cd -> {
+              try {
+                return forProject.change(cd).test(ChangePermission.READ);
+              } catch (PermissionBackendException e) {
+                throw new StorageException(e);
+              }
+            })
+        .collect(toImmutableMap(ChangeData::getId, Function.identity()));
+  }
+
+  /** Get a stream of changes by loading them individually. */
+  private static Stream<ChangeData> loadChangeDatasOneByOne(
+      Set<Change.Id> ids, ChangeData.Factory changeDataFactory, Project.NameKey projectName) {
+    return ids.stream()
+        .map(
+            id -> {
+              try {
+                ChangeData cd = changeDataFactory.create(projectName, id);
+                cd.notes(); // Make sure notes are available. This will trigger loading notes and
+                // throw an exception in case the change is corrupt and can't be loaded. It will
+                // then be omitted from the result.
+                return cd;
+              } catch (Exception e) {
+                // We drop changes that we can't load. The repositories contain 'dead' change refs
+                // and we want to overall operation to continue.
+                logger.atFinest().withCause(e).log("Can't load Change notes for %s", id);
+                return null;
+              }
+            })
+        .filter(Objects::nonNull);
+  }
+
+  /** Get a stream of all changes by scanning the repo. This is extremely slow. */
+  private static Stream<ChangeData> scanRepoForChangeDatas(
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      Repository repository,
+      Project.NameKey projectName) {
+    Stream<ChangeData> cds;
+    try {
+      cds =
+          changeNotesFactory
+              .scan(repository, projectName)
+              .map(
+                  notesResult -> {
+                    if (!notesResult.error().isPresent()) {
+                      return changeDataFactory.create(notesResult.notes());
+                    }
+                    logger.atWarning().withCause(notesResult.error().get()).log(
+                        "Unable to load ChangeNotes for %s", notesResult.id());
+                    return null;
+                  })
+              .filter(Objects::nonNull);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+    return cds;
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 664d867..e4fa1c4 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -346,7 +346,6 @@
   }
 
   private class ForProjectImpl extends ForProject {
-    private DefaultRefFilter refFilter;
     private String resourcePath;
 
     @Override
@@ -415,10 +414,7 @@
     @Override
     public Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
-      if (refFilter == null) {
-        refFilter = refFilterFactory.create(ProjectControl.this);
-      }
-      return refFilter.filter(refs, repo, opts);
+      return refFilterFactory.create(ProjectControl.this).filter(refs, repo, opts);
     }
 
     private boolean can(CoreOrPluginProjectPermission perm) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
deleted file mode 100644
index 552f4f6..0000000
--- a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright (C) 2021 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.permissions;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * Gets all of the visible by current user changes in the repository that are available in the
- * change index and cache.
- */
-class VisibleChangesCache {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  interface Factory {
-    VisibleChangesCache create(ProjectControl projectControl, Repository repository);
-  }
-
-  @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final ProjectState projectState;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final PermissionBackend.ForProject permissionBackendForProject;
-
-  private final Repository repository;
-  private Map<Change.Id, BranchNameKey> visibleChanges;
-
-  @Inject
-  VisibleChangesCache(
-      @Nullable SearchingChangeCacheImpl changeCache,
-      PermissionBackend permissionBackend,
-      ChangeNotes.Factory changeNotesFactory,
-      @Assisted ProjectControl projectControl,
-      @Assisted Repository repository) {
-    this.changeCache = changeCache;
-    this.projectState = projectControl.getProjectState();
-    this.permissionBackendForProject =
-        permissionBackend.user(projectControl.getUser()).project(projectState.getNameKey());
-    this.changeNotesFactory = changeNotesFactory;
-    this.repository = repository;
-  }
-
-  /**
-   * Returns {@code true} if the {@code changeId} in repository {@code repo} is visible to the user,
-   * by looking at the cached visible changes.
-   */
-  public boolean isVisible(Change.Id changeId) throws PermissionBackendException {
-    cachedVisibleChanges();
-    return visibleChanges.containsKey(changeId);
-  }
-
-  /**
-   * Returns the visible changes in the repository {@code repo}. If not cached, computes the visible
-   * changes and caches them.
-   */
-  public Map<Change.Id, BranchNameKey> cachedVisibleChanges() throws PermissionBackendException {
-    if (visibleChanges == null) {
-      if (changeCache == null) {
-        visibleChangesByScan();
-      } else {
-        visibleChangesBySearch();
-      }
-      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
-    }
-    return visibleChanges;
-  }
-
-  /**
-   * Returns the {@code BranchNameKey} for {@code changeId}. If not cached, computes *all* visible
-   * changes and caches them before returning this specific change. If not visible or not found,
-   * returns {@code null}.
-   */
-  @Nullable
-  public BranchNameKey getBranchNameKey(Change.Id changeId) throws PermissionBackendException {
-    return cachedVisibleChanges().get(changeId);
-  }
-
-  private void visibleChangesBySearch() throws PermissionBackendException {
-    visibleChanges = new HashMap<>();
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      for (ChangeData cd : changeCache.getChangeData(project)) {
-        if (!projectState.statePermitsRead()) {
-          continue;
-        }
-        if (permissionBackendForProject.change(cd).test(ChangePermission.READ)) {
-          visibleChanges.put(cd.getId(), cd.change().getDest());
-        }
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", project);
-    }
-  }
-
-  private void visibleChangesByScan() throws PermissionBackendException {
-    visibleChanges = new HashMap<>();
-    Project.NameKey p = projectState.getNameKey();
-    ImmutableList<ChangeNotesResult> changes;
-    try {
-      changes = changeNotesFactory.scan(repository, p).collect(toImmutableList());
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", p);
-      return;
-    }
-
-    for (ChangeNotesResult notesResult : changes) {
-      ChangeNotes notes = toNotes(notesResult);
-      if (notes != null) {
-        visibleChanges.put(notes.getChangeId(), notes.getChange().getDest());
-      }
-    }
-  }
-
-  @Nullable
-  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
-    if (r.error().isPresent()) {
-      logger.atWarning().withCause(r.error().get()).log(
-          "Failed to load change %s in %s", r.id(), projectState.getName());
-      return null;
-    }
-
-    if (!projectState.statePermitsRead()) {
-      return null;
-    }
-
-    if (permissionBackendForProject.change(r.notes()).test(ChangePermission.READ)) {
-      return r.notes();
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index 639b278..60dff84 100644
--- a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -49,6 +49,7 @@
     bind(String.class)
         .annotatedWith(PluginCanonicalWebUrl.class)
         .toInstance(plugin.getPluginCanonicalWebUrl());
+    bind(Plugin.class).toInstance(plugin);
 
     install(
         new LifecycleModule() {
diff --git a/java/com/google/gerrit/server/project/LabelConfigValidator.java b/java/com/google/gerrit/server/project/LabelConfigValidator.java
new file mode 100644
index 0000000..17cc468
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.java
@@ -0,0 +1,278 @@
+// 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.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+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.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Validates modifications to label configurations in the {@code project.config} file that is stored
+ * in {@code refs/meta/config}.
+ *
+ * <p>Rejects setting/changing deprecated fields (fields {@code copyAnyScore}, {@code copyMinScore},
+ * {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code copyAllScoresIfNoCodeChange},
+ * {@code copyAllScoresOnMergeFirstParentUpdate}, {@code copyAllScoresOnTrivialRebase}, {@code
+ * copyAllScoresIfListOfFilesDidNotChange}, {@code copyValue}).
+ *
+ * <p>Updates that unset the deprecated fields or that don't touch them are allowed.
+ */
+@Singleton
+public class LabelConfigValidator implements CommitValidationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  // Map of deprecated boolean flags to the predicates that should be used in the copy condition
+  // instead.
+  private static final ImmutableMap<String, String> DEPRECATED_FLAGS =
+      ImmutableMap.<String, String>builder()
+          .put(ProjectConfig.KEY_COPY_ANY_SCORE, "is:ANY")
+          .put(ProjectConfig.KEY_COPY_MIN_SCORE, "is:MIN")
+          .put(ProjectConfig.KEY_COPY_MAX_SCORE, "is:MAX")
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              "changekind:" + ChangeKind.NO_CHANGE.name())
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              "changekind:" + ChangeKind.NO_CODE_CHANGE.name())
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              "changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name())
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              "changekind:" + ChangeKind.TRIVIAL_REBASE.name())
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              "has:unchanged-files")
+          .build();
+
+  private final DiffOperations diffOperations;
+
+  @Inject
+  public LabelConfigValidator(DiffOperations diffOperations) {
+    this.diffOperations = diffOperations;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    try {
+      if (!receiveEvent.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(receiveEvent, ProjectConfig.PROJECT_CONFIG)) {
+        // The project.config file in refs/meta/config was not modified, hence we do not need to do
+        // any validation and can return early.
+        return ImmutableList.of();
+      }
+
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder =
+          ImmutableList.builder();
+
+      // Load the new config
+      Config newConfig;
+      try {
+        newConfig = loadNewConfig(receiveEvent);
+      } catch (ConfigInvalidException e) {
+        // The current config is invalid, hence we cannot inspect the delta.
+        // Rejecting invalid configs is not the responsibility of this validator, hence ignore this
+        // exception here.
+        logger.atWarning().log(
+            "cannot inspect the project config, because parsing %s from revision %s"
+                + " in project %s failed: %s",
+            ProjectConfig.PROJECT_CONFIG,
+            receiveEvent.commit.name(),
+            receiveEvent.getProjectNameKey(),
+            e.getMessage());
+        return ImmutableList.of();
+      }
+
+      // Load the old config
+      Optional<Config> oldConfig = loadOldConfig(receiveEvent);
+
+      for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) {
+        for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
+          if (flagChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName, deprecatedFlag)) {
+            validationMessageBuilder.add(
+                new CommitValidationMessage(
+                    String.format(
+                        "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                            + " use '%s' in '%s.%s.%s' instead.",
+                        ProjectConfig.LABEL,
+                        labelName,
+                        deprecatedFlag,
+                        DEPRECATED_FLAGS.get(deprecatedFlag),
+                        ProjectConfig.LABEL,
+                        labelName,
+                        ProjectConfig.KEY_COPY_CONDITION),
+                    ValidationMessage.Type.ERROR));
+          }
+        }
+
+        if (copyValuesChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName)) {
+          validationMessageBuilder.add(
+              new CommitValidationMessage(
+                  String.format(
+                      "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                          + " use 'is:<copy-value>' in '%s.%s.%s' instead.",
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_COPY_VALUE,
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_COPY_CONDITION),
+                  ValidationMessage.Type.ERROR));
+        }
+      }
+
+      ImmutableList<CommitValidationMessage> validationMessages = validationMessageBuilder.build();
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid %s file in revision %s",
+                ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()),
+            validationMessages);
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException e) {
+      String errorMessage =
+          String.format(
+              "failed to validate file %s for revision %s in ref %s of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              receiveEvent.commit.getName(),
+              RefNames.REFS_CONFIG,
+              receiveEvent.getProjectNameKey());
+      logger.atSevere().withCause(e).log("%s", errorMessage);
+      throw new CommitValidationException(errorMessage, e);
+    }
+  }
+
+  /**
+   * 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 {
+    Map<String, FileDiffOutput> fileDiffOutputs;
+    if (receiveEvent.commit.getParentCount() > 0) {
+      // normal commit with one parent: use listModifiedFilesAgainstParent with parentNum = 1 to
+      // compare against the only parent (using parentNum = 0 to compare against the default parent
+      // would also work)
+      // merge commit with 2 or more parents: must use listModifiedFilesAgainstParent with parentNum
+      // = 1 to compare against the first parent (using parentNum = 0 would compare against the
+      // auto-merge)
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(), receiveEvent.commit, 1, DiffOptions.DEFAULTS);
+    } else {
+      // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(),
+              receiveEvent.commit,
+              /* parentNum=*/ 0,
+              DiffOptions.DEFAULTS);
+    }
+    return fileDiffOutputs.keySet().contains(fileName);
+  }
+
+  private Config loadNewConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
+    return bareConfig.getConfig();
+  }
+
+  private Optional<Config> loadOldConfig(CommitReceivedEvent receiveEvent) throws IOException {
+    if (receiveEvent.commit.getParentCount() == 0) {
+      // initial commit, an old config doesn't exist
+      return Optional.empty();
+    }
+
+    try {
+      ProjectLevelConfig.Bare bareConfig =
+          new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+      bareConfig.load(
+          receiveEvent.project.getNameKey(),
+          receiveEvent.revWalk,
+          receiveEvent.commit.getParent(0));
+      return Optional.of(bareConfig.getConfig());
+    } catch (ConfigInvalidException e) {
+      // the old config is not parseable, treat this the same way as if an old config didn't exist
+      // so that all parameters in the new config are validated
+      logger.atWarning().log(
+          "cannot inspect the old project config, because parsing %s from parent revision %s"
+              + " in project %s failed: %s",
+          ProjectConfig.PROJECT_CONFIG,
+          receiveEvent.commit.name(),
+          receiveEvent.getProjectNameKey(),
+          e.getMessage());
+      return Optional.empty();
+    }
+  }
+
+  private static boolean flagChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
+    if (oldConfig == null) {
+      return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(key);
+    }
+
+    // Use getString rather than getBoolean so that we do not have to deal with values that cannot
+    // be parsed as a boolean.
+    String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
+    String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
+    return newValue != null && !newValue.equals(oldValue);
+  }
+
+  private static boolean copyValuesChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName) {
+    if (oldConfig == null) {
+      return newConfig
+          .getNames(ProjectConfig.LABEL, labelName)
+          .contains(ProjectConfig.KEY_COPY_VALUE);
+    }
+
+    // Ignore the order in which the copy values are defined in the new and old config, since the
+    // order doesn't matter for this parameter.
+    ImmutableSet<String> oldValues =
+        ImmutableSet.copyOf(
+            oldConfig.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_VALUE));
+    ImmutableSet<String> newValues =
+        ImmutableSet.copyOf(
+            newConfig.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_VALUE));
+    return !newValues.isEmpty() && !Sets.difference(newValues, oldValues).isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 123a14c..d816d84 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -522,12 +522,6 @@
     return sort(contributorAgreements.values());
   }
 
-  public void remove(ContributorAgreement section) {
-    if (section != null) {
-      accessSections.remove(section.getName());
-    }
-  }
-
   public void replace(ContributorAgreement section) {
     ContributorAgreement.Builder ca = section.toBuilder();
     ca.setAutoVerify(resolve(section.getAutoVerify()));
@@ -1179,6 +1173,10 @@
               LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
       Set<Short> copyValues = new HashSet<>();
       for (String value : rc.getStringList(LABEL, name, KEY_COPY_VALUE)) {
+        if (value == null) {
+          // value is null if copyValue in project.config is set to an empty string
+          continue;
+        }
         try {
           short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
           if (!copyValues.add(copyValue)) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 177ce0d..1361122 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Label;
@@ -115,8 +116,14 @@
   }
 
   private static List<SubmitRequirementResult> createFromDefaultSubmitRecord(
-      List<Label> labels, List<LabelType> labelTypes, ObjectId psCommitId, boolean isForced) {
+      @Nullable List<Label> labels,
+      List<LabelType> labelTypes,
+      ObjectId psCommitId,
+      boolean isForced) {
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
+    if (labels == null) {
+      return result.build();
+    }
     for (Label label : labels) {
       if (skipSubmitRequirementFor(label)) {
         continue;
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 3d2e519..b6926b2 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
@@ -48,6 +50,7 @@
   private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
   private final SubmitRequirementsUtil submitRequirementsUtil;
   private final OneOffRequestContext requestContext;
+  private final ExperimentFeatures experimentFeatures;
 
   public static Module module() {
     return new AbstractModule() {
@@ -66,12 +69,14 @@
       ProjectCache projectCache,
       PluginSetContext<SubmitRequirement> globalSubmitRequirements,
       SubmitRequirementsUtil submitRequirementsUtil,
-      OneOffRequestContext requestContext) {
+      OneOffRequestContext requestContext,
+      ExperimentFeatures experimentFeatures) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
     this.globalSubmitRequirements = globalSubmitRequirements;
     this.submitRequirementsUtil = submitRequirementsUtil;
     this.requestContext = requestContext;
+    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -91,7 +96,7 @@
     Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
         SubmitRequirementsAdapter.getLegacyRequirements(cd);
     return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-        projectConfigRequirements, legacyReqs, cd.change());
+        projectConfigRequirements, legacyReqs, cd);
   }
 
   @Override
@@ -105,8 +110,23 @@
           sr.applicabilityExpression().isPresent()
               ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
               : Optional.empty();
-      Optional<SubmitRequirementExpressionResult> submittabilityResult = Optional.empty();
-      Optional<SubmitRequirementExpressionResult> overrideResult = Optional.empty();
+      Optional<SubmitRequirementExpressionResult> submittabilityResult;
+      Optional<SubmitRequirementExpressionResult> overrideResult;
+      if (experimentFeatures.isFeatureEnabled(
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED)) {
+        submittabilityResult =
+            Optional.of(
+                SubmitRequirementExpressionResult.notEvaluated(sr.submittabilityExpression()));
+        overrideResult =
+            sr.overrideExpression().isPresent()
+                ? Optional.of(
+                    SubmitRequirementExpressionResult.notEvaluated(sr.overrideExpression().get()))
+                : Optional.empty();
+      } else {
+        submittabilityResult = Optional.empty();
+        overrideResult = Optional.empty();
+      }
       if (!sr.applicabilityExpression().isPresent()
           || SubmitRequirementResult.assertPass(applicabilityResult)) {
         submittabilityResult = Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index 9819090..c234c8c 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Counter2;
@@ -23,6 +22,8 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.HashMap;
@@ -127,7 +128,7 @@
       mergeLegacyAndNonLegacyRequirements(
           Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
           Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements,
-          Change change) {
+          ChangeData cd) {
     // Cannot use ImmutableMap.Builder here since entries in the map may be overridden.
     Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
     result.putAll(projectConfigRequirements);
@@ -143,8 +144,8 @@
       // then add the legacy SR to the result. There is no mismatch in results in this case.
       if (projectConfigResult == null) {
         result.put(legacy.getKey(), legacy.getValue());
-        if (change.getStatus().isOpen()) {
-          metrics.legacyNotInSrs.increment(change.getProject().get(), srName);
+        if (shouldReportMetric(cd)) {
+          metrics.legacyNotInSrs.increment(cd.project().get(), srName);
         }
         continue;
       }
@@ -152,32 +153,38 @@
         // There exists a project config SR with the same name as the legacy SR, and they are
         // matching in result. No need to include the legacy SR in the output since the project
         // config SR is already there.
-        if (change.getStatus().isOpen()) {
-          metrics.submitRequirementsMatchingWithLegacy.increment(change.getProject().get(), srName);
+        if (shouldReportMetric(cd)) {
+          metrics.submitRequirementsMatchingWithLegacy.increment(cd.project().get(), srName);
         }
         continue;
       }
       // There exists a project config SR with the same name as the legacy SR but they are not
       // matching in their result. Increment the mismatch count and add the legacy SR to the result.
-      if (change.getStatus().isOpen()) {
-        metrics.submitRequirementsMismatchingWithLegacy.increment(
-            change.getProject().get(), srName);
+      if (shouldReportMetric(cd)) {
+        metrics.submitRequirementsMismatchingWithLegacy.increment(cd.project().get(), srName);
       }
       result.put(legacy.getKey(), legacy.getValue());
     }
     Set<String> legacyNames =
         legacyRequirements.keySet().stream()
             .map(SubmitRequirement::name)
+            .map(String::toLowerCase)
             .collect(Collectors.toSet());
     for (String projectConfigSrName : requirementsByName.keySet()) {
-      if (!legacyNames.contains(projectConfigSrName) && change.getStatus().isOpen()) {
-        metrics.srsNotInLegacy.increment(change.getProject().get(), projectConfigSrName);
+      if (!legacyNames.contains(projectConfigSrName) && shouldReportMetric(cd)) {
+        metrics.srsNotInLegacy.increment(cd.project().get(), projectConfigSrName);
       }
     }
 
     return ImmutableMap.copyOf(result);
   }
 
+  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.
+    return cd.change().isNew() && cd.getStorageConstraint() == StorageConstraint.NOTEDB_ONLY;
+  }
+
   /** Returns true if both input results are equal in allowing/disallowing change submission. */
   private static boolean matchByStatus(SubmitRequirementResult r1, SubmitRequirementResult r2) {
     return r1.fulfilled() == r2.fulfilled();
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index d7bbe46..32b87ec 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -32,8 +32,8 @@
         value(2, "Looks good to me, approved"),
         value(1, "Looks good to me, but someone else must approve"),
         value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
+        value(-1, "I would prefer this is not submitted as is"),
+        value(-2, "This shall not be submitted"));
   }
 
   public static LabelType verified() {
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 8f94089..dd8d685 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -66,7 +66,7 @@
 
   public static Predicate<AccountState> id(Schema<AccountState> schema, Account.Id accountId) {
     return new AccountPredicate(
-        schema.useLegacyNumericFields() ? AccountField.ID : AccountField.ID_STR,
+        schema.hasField(AccountField.ID) ? AccountField.ID : AccountField.ID_STR,
         AccountQueryBuilder.FIELD_ACCOUNT,
         accountId.toString());
   }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 2839eb7..6e433c5 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -43,6 +43,9 @@
   /** {@link ChangeKind} of the delta between the origin and target patch set. */
   public abstract ChangeKind changeKind();
 
+  /** Whether the new patch set is a merge commit. */
+  public abstract boolean isMerge();
+
   /** {@link RevWalk} of the repository for the current commit. */
   public abstract RevWalk revWalk();
 
@@ -54,6 +57,7 @@
       PatchSetApproval psa,
       PatchSet patchSet,
       ChangeKind changeKind,
+      boolean isMerge,
       RevWalk revWalk,
       Config repoConfig) {
     checkState(
@@ -68,6 +72,6 @@
     // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
     // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
     return new AutoValue_ApprovalContext(
-        psa, patchSet, changeNotes, changeKind, revWalk, repoConfig);
+        psa, patchSet, changeNotes, changeKind, isMerge, revWalk, repoConfig);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index 819f319..11749cc 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.query.approval;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Enums;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.client.ChangeKind;
@@ -24,6 +28,7 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.inject.Inject;
 import java.util.Arrays;
+import java.util.Optional;
 
 public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
   private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef =
@@ -51,13 +56,37 @@
   }
 
   @Operator
-  public Predicate<ApprovalContext> changekind(String term) throws QueryParseException {
-    return new ChangeKindPredicate(toEnumValue(ChangeKind.class, term));
+  public Predicate<ApprovalContext> changekind(String value) throws QueryParseException {
+    return parseEnumValue(ChangeKind.class, value)
+        .map(ChangeKindPredicate::new)
+        .orElseThrow(
+            () ->
+                new QueryParseException(
+                    String.format(
+                        "%s is not a valid value for operator 'changekind'. Valid values: %s",
+                        value, formatEnumValues(ChangeKind.class))));
   }
 
   @Operator
-  public Predicate<ApprovalContext> is(String term) throws QueryParseException {
-    return magicValuePredicate.create(toEnumValue(MagicValuePredicate.MagicValue.class, term));
+  public Predicate<ApprovalContext> is(String value) throws QueryParseException {
+    // try to parse exact value
+    Optional<Integer> exactValue = Optional.ofNullable(Ints.tryParse(value));
+    if (exactValue.isPresent()) {
+      return new ExactValuePredicate(exactValue.get().shortValue());
+    }
+
+    // try to parse magic value
+    Optional<MagicValuePredicate.MagicValue> magicValue =
+        parseEnumValue(MagicValuePredicate.MagicValue.class, value);
+    if (magicValue.isPresent()) {
+      return magicValuePredicate.create(magicValue.get());
+    }
+
+    // it's neither an exact value nor a magic value
+    throw new QueryParseException(
+        String.format(
+            "%s is not a valid value for operator 'is'. Valid values: %s or integer",
+            value, formatEnumValues(MagicValuePredicate.MagicValue.class)));
   }
 
   @Operator
@@ -77,21 +106,21 @@
     }
     throw error(
         String.format(
-            "'%s' is not a supported argument for has. only 'unchanged-files' is supported",
+            "'%s' is not a valid value for operator 'has'."
+                + " The only valid value is 'unchanged-files'.",
             value));
   }
 
-  private static <T extends Enum<T>> T toEnumValue(Class<T> clazz, String term)
-      throws QueryParseException {
-    try {
-      return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
-    } catch (IllegalArgumentException e) {
-      throw new QueryParseException(
-          String.format(
-              "%s is not a valid term. valid options: %s",
-              term, Arrays.asList(clazz.getEnumConstants())),
-          e);
-    }
+  private static <T extends Enum<T>> Optional<T> parseEnumValue(Class<T> clazz, String value) {
+    return Optional.ofNullable(
+        Enums.getIfPresent(clazz, value.toUpperCase().replace('-', '_')).orNull());
+  }
+
+  private <T extends Enum<T>> String formatEnumValues(Class<T> clazz) {
+    return Arrays.stream(clazz.getEnumConstants())
+        .map(Object::toString)
+        .sorted()
+        .collect(joining(", "));
   }
 
   private AccountGroup.UUID parseGroupOrThrow(String maybeUUID) throws QueryParseException {
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
index 78711fd..daf437b 100644
--- a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -32,7 +32,7 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    return ctx.changeKind().equals(changeKind);
+    return ctx.changeKind().matches(changeKind, ctx.isMerge());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
new file mode 100644
index 0000000..1f36f8a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.index.query.Predicate;
+import java.util.Collection;
+
+/** Predicate that matches patch set approvals that have a given voting value. */
+public class ExactValuePredicate extends ApprovalPredicate {
+  private final short votingValue;
+
+  public ExactValuePredicate(short votingValue) {
+    this.votingValue = votingValue;
+  }
+
+  @Override
+  public boolean match(ApprovalContext approvalContext) {
+    return votingValue == approvalContext.patchSetApproval().value();
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ExactValuePredicate(votingValue);
+  }
+
+  @Override
+  public int hashCode() {
+    return Short.valueOf(votingValue).hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    return (other instanceof ExactValuePredicate)
+        && votingValue == ((ExactValuePredicate) other).votingValue;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
index ac6720d..2aef703 100644
--- a/java/com/google/gerrit/server/query/approval/UserInPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.query.approval;
 
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 16811b7..b99f746 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -76,7 +76,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.patch.DiffSummary;
@@ -289,7 +289,7 @@
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
   private final GitRepositoryManager repoManager;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
   private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
@@ -371,7 +371,7 @@
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
       GitRepositoryManager repoManager,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
       PatchListCache patchListCache,
       PatchSetUtil psUtil,
@@ -942,7 +942,7 @@
   }
 
   /**
-   * Similar to {@link #submitRequirements}, except that it also converts submit records resulting
+   * Similar to {@link #submitRequirements()}, except that it also converts submit records resulting
    * from the evaluation of legacy submit rules to submit requirements.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirementsIncludingLegacy() {
@@ -950,7 +950,7 @@
     Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
         SubmitRequirementsAdapter.getLegacyRequirements(this);
     return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-        projectConfigReqs, legacyReqs, change());
+        projectConfigReqs, legacyReqs, this);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index ce17b31..c874db7 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -136,15 +136,6 @@
    * Returns a predicate that matches the change with the provided {@link
    * com.google.gerrit.entities.Change.Id}.
    */
-  public static Predicate<ChangeData> id(Change.Id id) {
-    return new ChangeIndexPredicate(
-        ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
-  }
-
-  /**
-   * Returns a predicate that matches the change with the provided {@link
-   * com.google.gerrit.entities.Change.Id}.
-   */
   public static Predicate<ChangeData> idStr(Change.Id id) {
     return new ChangeIndexPredicate(
         ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 34c9839..9e9a960 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -60,6 +60,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.DestinationList;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupMembers;
@@ -96,6 +97,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -479,6 +481,7 @@
 
   private final Arguments args;
   protected Map<String, String> hasOperandAliases = Collections.emptyMap();
+  private Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
 
   private static final Splitter RULE_SPLITTER = Splitter.on("=");
   private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
@@ -556,9 +559,7 @@
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return args.getSchema().useLegacyNumericFields()
-            ? ChangePredicates.id(Change.id(id))
-            : ChangePredicates.idStr(Change.id(id));
+        return ChangePredicates.idStr(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return ChangePredicates.idPrefix(parseChangeId(query));
@@ -915,6 +916,12 @@
     return file(file);
   }
 
+  /**
+   * Creates a predicate to match changes by file.
+   *
+   * @param file the value of the {@code file} query operator
+   * @throws QueryParseException thrown if parsing the value fails (may be thrown by subclasses)
+   */
   @Operator
   public Predicate<ChangeData> file(String file) throws QueryParseException {
     if (file.startsWith("^")) {
@@ -1491,9 +1498,7 @@
         account = self();
       }
 
-      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
-      d.load(args.allUsersName, git);
-      Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
+      Set<BranchNameKey> destinations = getDestinationList(git, account).getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, value);
       }
@@ -1506,6 +1511,23 @@
     throw new QueryParseException("Unknown named destination: " + name);
   }
 
+  protected DestinationList getDestinationList(Repository git, Account.Id account)
+      throws ConfigInvalidException, RepositoryNotFoundException, IOException {
+    DestinationList dl = destinationListByAccount.get(account);
+    if (dl == null) {
+      dl = loadDestinationList(git, account);
+      destinationListByAccount.put(account, dl);
+    }
+    return dl;
+  }
+
+  protected DestinationList loadDestinationList(Repository git, Account.Id account)
+      throws ConfigInvalidException, RepositoryNotFoundException, IOException {
+    VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
+    d.load(args.allUsersName, git);
+    return d.getDestinationList();
+  }
+
   @Operator
   public Predicate<ChangeData> author(String who) throws QueryParseException {
     return getAuthorOrCommitterPredicate(
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index f95dbb0..fc4c1d0 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -89,11 +89,7 @@
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(ChangePredicates.project(c.getProject()));
     and.add(ChangePredicates.ref(c.getDest().branch()));
-    and.add(
-        Predicate.not(
-            args.getSchema().useLegacyNumericFields()
-                ? ChangePredicates.id(c.getId())
-                : ChangePredicates.idStr(c.getId())));
+    and.add(Predicate.not(ChangePredicates.idStr(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
index 441e4c4..5a51f5d 100644
--- a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
@@ -40,7 +40,7 @@
  */
 public class DistinctVotersPredicate extends SubmitRequirementPredicate {
   public interface Factory {
-    DistinctVotersPredicate create(String value);
+    DistinctVotersPredicate create(String value) throws QueryParseException;
   }
 
   private static final Pattern PATTERN =
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index e7b25fb..99c1ca1 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -56,11 +56,6 @@
  * holding on to a single instance.
  */
 public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChangeQuery> {
-  @FunctionalInterface
-  static interface ChangeIdPredicateFactory {
-    Predicate<ChangeData> create(Change.Id id);
-  }
-
   private static Predicate<ChangeData> ref(BranchNameKey branch) {
     return ChangePredicates.ref(branch.branch());
   }
@@ -84,9 +79,6 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final ChangeIdPredicateFactory predicateFactory;
-
   @Inject
   InternalChangeQuery(
       ChangeQueryProcessor queryProcessor,
@@ -97,11 +89,6 @@
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
-    predicateFactory =
-        (id) ->
-            schema().useLegacyNumericFields()
-                ? ChangePredicates.id(id)
-                : ChangePredicates.idStr(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -113,13 +100,13 @@
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
-    return query(predicateFactory.create(id));
+    return query(ChangePredicates.idStr(id));
   }
 
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
-      preds.add(predicateFactory.create(id));
+      preds.add(ChangePredicates.idStr(id));
     }
     return query(or(preds));
   }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 2864391..2566b72 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -8,6 +8,7 @@
     name = "restapi",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
@@ -29,6 +30,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
+        "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index fbd99eb..fbb4fbd 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -70,7 +70,7 @@
 
   private final Provider<CurrentUser> userProvider;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeJson.Factory changeJsonFactory;
@@ -83,7 +83,7 @@
   DeleteDraftComments(
       Provider<CurrentUser> userProvider,
       BatchUpdate.Factory batchUpdateFactory,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
       ChangeJson.Factory changeJsonFactory,
@@ -93,7 +93,7 @@
       ExperimentFeatures experimentFeatures) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
     this.changeJsonFactory = changeJsonFactory;
@@ -161,7 +161,7 @@
       return hasDraft;
     }
     try {
-      return Predicate.and(hasDraft, queryBuilderProvider.get().parse(input.query));
+      return Predicate.and(hasDraft, queryBuilder.parse(input.query));
     } catch (QueryParseException e) {
       throw new BadRequestException("Invalid query: " + e.getMessage(), e);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index 5979b2a..2131070 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryParser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
@@ -95,6 +98,17 @@
         throw new BadRequestException("project name must be specified");
       }
 
+      if (!Strings.isNullOrEmpty(info.filter)) {
+        try {
+          QueryParser.parse(info.filter);
+        } catch (QueryParseException e) {
+          throw new BadRequestException(
+              String.format(
+                  "invalid filter expression for project %s: %s", info.project, e.getMessage()),
+              e);
+        }
+      }
+
       ProjectWatchKey key =
           ProjectWatchKey.create(projectsCollection.parse(info.project).getNameKey(), info.filter);
       if (m.containsKey(key)) {
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index e09f2f4..e4b9ca7 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -210,9 +210,11 @@
     factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteVoteOp.Factory.class);
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(AddReviewersOp.Factory.class);
+    factory(PostReviewOp.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index cb08c11..232cd77 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
@@ -108,7 +109,7 @@
   private final ChangeInserter.Factory changeInserterFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final SetCherryPickOp.Factory setCherryPickOfFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
@@ -125,7 +126,7 @@
       ChangeInserter.Factory changeInserterFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       SetCherryPickOp.Factory setCherryPickOfFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeNotes.Factory changeNotesFactory,
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 6a637b3..e668891 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -127,7 +128,7 @@
   private final ChangeFinder changeFinder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final SubmitType submitType;
   private final NotifyResolver notifyResolver;
   private final ContributorAgreementsChecker contributorAgreements;
@@ -150,7 +151,7 @@
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       NotifyResolver notifyResolver,
       ContributorAgreementsChecker contributorAgreements) {
     this.updateFactory = updateFactory;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 651bf7b..7259deb 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -89,7 +90,7 @@
   private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
   private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ProjectCache projectCache;
   private final ChangeFinder changeFinder;
@@ -104,7 +105,7 @@
       Provider<CurrentUser> user,
       ChangeJson.Factory json,
       PatchSetUtil psUtil,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       ProjectCache projectCache,
       ChangeFinder changeFinder,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 208cecf..aeaae9f 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -15,102 +15,51 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
-import com.google.gerrit.server.extensions.events.VoteDeleted;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.AccountTemplateUtil;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final BatchUpdate.Factory updateFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyResolver notifyResolver;
-  private final RemoveReviewerControl removeReviewerControl;
-  private final ProjectCache projectCache;
-  private final MessageIdGenerator messageIdGenerator;
+
   private final AddToAttentionSetOp.Factory attentionSetOpFactory;
   private final Provider<CurrentUser> currentUserProvider;
+  private final DeleteVoteOp.Factory deleteVoteOpFactory;
 
   @Inject
   DeleteVote(
       BatchUpdate.Factory updateFactory,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
-      RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache,
-      MessageIdGenerator messageIdGenerator,
       AddToAttentionSetOp.Factory attentionSetOpFactory,
-      Provider<CurrentUser> currentUserProvider) {
+      Provider<CurrentUser> currentUserProvider,
+      DeleteVoteOp.Factory deleteVoteOpFactory) {
     this.updateFactory = updateFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyResolver = notifyResolver;
-    this.removeReviewerControl = removeReviewerControl;
-    this.projectCache = projectCache;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetOpFactory = attentionSetOpFactory;
     this.currentUserProvider = currentUserProvider;
+    this.deleteVoteOpFactory = deleteVoteOpFactory;
   }
 
   @Override
@@ -140,13 +89,8 @@
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
       bu.addOp(
           change.getId(),
-          new Op(
-              projectCache
-                  .get(r.getChange().getProject())
-                  .orElseThrow(illegalState(r.getChange().getProject())),
-              r.getReviewerUser().state(),
-              rsrc.getLabel(),
-              input));
+          deleteVoteOpFactory.create(
+              r.getChange().getProject(), r.getReviewerUser().state(), rsrc.getLabel(), input));
       if (!input.ignoreAutomaticAttentionSetRules
           && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
@@ -164,109 +108,4 @@
 
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final AccountState accountState;
-    private final String label;
-    private final DeleteVoteInput input;
-
-    private String mailMessage;
-    private Change change;
-    private PatchSet ps;
-    private Map<String, Short> newApprovals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(
-        ProjectState projectState, AccountState accountState, String label, DeleteVoteInput input) {
-      this.projectState = projectState;
-      this.accountState = accountState;
-      this.label = label;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
-      change = ctx.getChange();
-      PatchSet.Id psId = change.currentPatchSetId();
-      ps = psUtil.current(ctx.getNotes());
-
-      boolean found = false;
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-
-      Account.Id accountId = accountState.account().id();
-
-      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
-        if (!labelTypes.byLabel(a.labelId()).isPresent()) {
-          continue; // Ignore undefined labels.
-        } else if (!a.label().equals(label)) {
-          // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.label(), a.value());
-          continue;
-        } else {
-          try {
-            removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-          } catch (AuthException e) {
-            throw new AuthException("delete vote not permitted", e);
-          }
-        }
-        // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.label(), (short) 0);
-        found = true;
-
-        // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.label(), a.value());
-        break;
-      }
-      if (!found) {
-        throw new ResourceNotFoundException();
-      }
-
-      ctx.getUpdate(psId).removeApprovalFor(accountId, label);
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed ");
-      LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
-      msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId)).append("\n");
-      mailMessage =
-          cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      return true;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-
-      IdentifiedUser user = ctx.getIdentifiedUser();
-      try {
-        NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (notify.shouldNotify()) {
-          ReplyToChangeSender emailSender =
-              deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          emailSender.setFrom(user.getAccountId());
-          emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-          emailSender.setNotify(notify);
-          emailSender.setMessageId(
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-          emailSender.send();
-        }
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
-      }
-
-      voteDeleted.fire(
-          ctx.getChangeData(change),
-          ps,
-          accountState,
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          mailMessage,
-          user.state(),
-          ctx.getWhen());
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
new file mode 100644
index 0000000..6a83940
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -0,0 +1,206 @@
+// 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.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.VoteDeleted;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Updates the storage to delete vote(s). */
+public class DeleteVoteOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Factory to create {@link DeleteVoteOp} instances. */
+  public interface Factory {
+    DeleteVoteOp create(
+        Project.NameKey projectState,
+        AccountState reviewerToDeleteVoteFor,
+        String label,
+        DeleteVoteInput input);
+  }
+
+  private final Project.NameKey projectName;
+  private final AccountState reviewerToDeleteVoteFor;
+
+  private final ProjectCache projectCache;
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final VoteDeleted voteDeleted;
+  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+
+  private final RemoveReviewerControl removeReviewerControl;
+  private final MessageIdGenerator messageIdGenerator;
+
+  private final String label;
+  private final DeleteVoteInput input;
+
+  private String mailMessage;
+  private Change change;
+  private PatchSet ps;
+  private Map<String, Short> newApprovals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  public DeleteVoteOp(
+      ProjectCache projectCache,
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      VoteDeleted voteDeleted,
+      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      RemoveReviewerControl removeReviewerControl,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted Project.NameKey projectName,
+      @Assisted AccountState reviewerToDeleteVoteFor,
+      @Assisted String label,
+      @Assisted DeleteVoteInput input) {
+    this.projectCache = projectCache;
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.voteDeleted = voteDeleted;
+    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.removeReviewerControl = removeReviewerControl;
+    this.messageIdGenerator = messageIdGenerator;
+
+    this.projectName = projectName;
+    this.reviewerToDeleteVoteFor = reviewerToDeleteVoteFor;
+    this.label = label;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
+    change = ctx.getChange();
+    PatchSet.Id psId = change.currentPatchSetId();
+    ps = psUtil.current(ctx.getNotes());
+
+    boolean found = false;
+    LabelTypes labelTypes =
+        projectCache
+            .get(projectName)
+            .orElseThrow(illegalState(projectName))
+            .getLabelTypes(ctx.getNotes());
+
+    Account.Id accountId = reviewerToDeleteVoteFor.account().id();
+
+    for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
+      if (!labelTypes.byLabel(a.labelId()).isPresent()) {
+        continue; // Ignore undefined labels.
+      } else if (!a.label().equals(label)) {
+        // Populate map for non-matching labels, needed by VoteDeleted.
+        newApprovals.put(a.label(), a.value());
+        continue;
+      } else if (!ctx.getUser().isInternalUser()) {
+        // For regular users, check if they are allowed to remove the vote.
+        try {
+          removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+        } catch (AuthException e) {
+          throw new AuthException("delete vote not permitted", e);
+        }
+      }
+      // Set the approval to 0 if vote is being removed.
+      newApprovals.put(a.label(), (short) 0);
+      found = true;
+
+      // Set old value, as required by VoteDeleted.
+      oldApprovals.put(a.label(), a.value());
+      break;
+    }
+    if (!found) {
+      throw new ResourceNotFoundException();
+    }
+
+    ctx.getUpdate(psId).removeApprovalFor(accountId, label);
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed ");
+    LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
+    msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId)).append("\n");
+    mailMessage = cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+
+    CurrentUser user = ctx.getUser();
+    try {
+      NotifyResolver.Result notify = ctx.getNotify(change.getId());
+      if (notify.shouldNotify()) {
+        ReplyToChangeSender emailSender =
+            deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+        if (user.isIdentifiedUser()) {
+          emailSender.setFrom(user.getAccountId());
+        }
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
+      }
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+    }
+    voteDeleted.fire(
+        ctx.getChangeData(change),
+        ps,
+        reviewerToDeleteVoteFor,
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        mailMessage,
+        user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null,
+        ctx.getWhen());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index d76ce04..5193501 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.concurrent.TimeUnit.DAYS;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -25,7 +27,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -64,10 +65,11 @@
                   commit,
                   addLinks,
                   /* fillCommit= */ true,
-                  rsrc.getChange().getDest().branch());
+                  rsrc.getChange().getDest().branch(),
+                  rsrc.getChange().getKey().get());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
-        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+        r.caching(CacheControl.PRIVATE(7, DAYS));
       }
       return r;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index dd951a8..9424198 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -184,6 +184,8 @@
     private final DiffSide sideB;
     private final String revA;
     private final String revB;
+    private final String hashA;
+    private final String hashB;
     private final FileResource resource;
     @Nullable private final PatchSet basePatchSet;
 
@@ -202,6 +204,7 @@
       this.sideB = sideB;
 
       revA = basePatchSet != null ? basePatchSet.refName() : sideA.fileInfo().commitId;
+      hashA = sideA.fileInfo().commitId;
 
       RevisionResource revision = resource.getRevision();
       revB =
@@ -209,8 +212,9 @@
               .getEdit()
               .map(edit -> edit.getRefName())
               .orElseGet(() -> revision.getPatchSet().refName());
+      hashB = sideB.fileInfo().commitId;
 
-      logger.atFine().log("revA = %s, revB = %s", revA, revB);
+      logger.atFine().log("revA = %s, hashA = %s, revB = %s, hashB = %s", revA, hashA, revB, hashB);
     }
 
     @Override
@@ -234,14 +238,19 @@
     @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
       String rev = getSideRev(type);
+      String hash = getSideHash(type);
       DiffSide side = getDiffSide(type);
-      return webLinks.getFileLinks(projectName.get(), rev, side.fileName());
+      return webLinks.getFileLinks(projectName.get(), rev, hash, side.fileName());
     }
 
     private String getSideRev(DiffSide.Type sideType) {
       return DiffSide.Type.SIDE_A == sideType ? revA : revB;
     }
 
+    private String getSideHash(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? hashA : hashB;
+    }
+
     private DiffSide getDiffSide(DiffSide.Type sideType) {
       return DiffSide.Type.SIDE_A == sideType ? sideA : sideB;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index f0639b5..551b50f 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.concurrent.TimeUnit.DAYS;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
@@ -30,7 +32,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -77,7 +78,7 @@
         return createResponse(rsrc, ImmutableList.of());
       }
 
-      List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
+      ImmutableList<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
       List<CommitInfo> result = new ArrayList<>(commits.size());
       RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
@@ -88,7 +89,8 @@
                 c,
                 addLinks,
                 /* fillCommit= */ true,
-                rsrc.getChange().getDest().branch()));
+                rsrc.getChange().getDest().branch(),
+                rsrc.getChange().getKey().get()));
       }
       return createResponse(rsrc, result);
     }
@@ -98,7 +100,7 @@
       RevisionResource rsrc, List<CommitInfo> result) {
     Response<List<CommitInfo>> r = Response.ok(result);
     if (rsrc.isCacheable()) {
-      r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      r.caching(CacheControl.PRIVATE(7, DAYS));
     }
     return r;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 7683ab7..8aa2554 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -61,7 +61,7 @@
 
   private final GitRepositoryManager gitManager;
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeIndexer indexer;
   private final MergeabilityCache cache;
@@ -71,7 +71,7 @@
   Mergeable(
       GitRepositoryManager gitManager,
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeData.Factory changeDataFactory,
       ChangeIndexer indexer,
       MergeabilityCache cache,
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 4605d7c..8b47e1e 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,23 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
@@ -44,16 +38,11 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -75,29 +64,20 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.extensions.validators.CommentForValidation;
-import com.google.gerrit.extensions.validators.CommentValidationContext;
-import com.google.gerrit.extensions.validators.CommentValidationFailure;
-import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
@@ -106,12 +86,10 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -121,26 +99,17 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -150,7 +119,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -186,21 +154,15 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
   private final BatchUpdate.Factory updateFactory;
+  private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
-  private final PublishCommentUtil publishCommentUtil;
-  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
-  private final EmailReviewComments.Factory email;
-  private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
   private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
@@ -208,28 +170,22 @@
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
-  private final PluginSetContext<CommentValidator> commentValidators;
-  private final PluginSetContext<OnPostReview> onPostReviews;
+
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
-  private final boolean publishPatchSetLevelComment;
 
   @Inject
   PostReview(
       BatchUpdate.Factory updateFactory,
+      PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountResolver accountResolver,
-      EmailReviewComments.Factory email,
-      CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
       Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
@@ -238,23 +194,17 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators,
-      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
+    this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
-    this.publishCommentUtil = publishCommentUtil;
-    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
     this.accountResolver = accountResolver;
-    this.email = email;
-    this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
     this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
@@ -262,13 +212,9 @@
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
-    this.commentValidators = commentValidators;
-    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
-    this.publishPatchSetLevelComment =
-        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
   }
 
   @Override
@@ -354,8 +300,13 @@
     }
     output.labels = input.labels;
 
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
     try (BatchUpdate bu =
         updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
+      bu.setNotify(notify);
+
       Account account = revision.getUser().asIdentifiedUser().getAccount();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
@@ -427,30 +378,27 @@
 
       // Add the review op.
       logger.atFine().log("posting review");
-      bu.addOp(
-          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
-
-      // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-      NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-      bu.setNotify(notify);
+      PostReviewOp postReviewOp =
+          postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
+      bu.addOp(revision.getChange().getId(), postReviewOp);
 
       // Adjust the attention set based on the input
       replyAttentionSetUpdates.updateAttentionSet(
           bu, revision.getNotes(), input, revision.getUser());
       bu.execute();
-
-      // Re-read change to take into account results of the update.
-      ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
-      for (ReviewerModification reviewerResult : reviewerResults) {
-        reviewerResult.gatherResults(cd);
-      }
-
-      // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
-      // email/event here.
-      batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
-      batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
     }
 
+    // Re-read change to take into account results of the update.
+    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.gatherResults(cd);
+    }
+
+    // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+    // email/event here.
+    batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+    batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
+
     return Response.ok(output);
   }
 
@@ -487,7 +435,9 @@
       Change change,
       List<ReviewerModification> reviewerModifications,
       NotifyResolver.Result notify) {
-    try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer(
+            getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) {
       List<Account.Id> to = new ArrayList<>();
       List<Account.Id> cc = new ArrayList<>();
       List<Account.Id> removed = new ArrayList<>();
@@ -698,10 +648,6 @@
         .collect(toList());
   }
 
-  private TraceContext.TraceTimer newTimer(String method) {
-    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
-  }
-
   private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
@@ -1008,643 +954,4 @@
     @Nullable
     abstract Comment.Range range();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final PatchSet.Id psId;
-    private final ReviewInput in;
-
-    private IdentifiedUser user;
-    private ChangeNotes notes;
-    private PatchSet ps;
-    private String mailMessage;
-    private List<Comment> comments = new ArrayList<>();
-    private List<LabelVote> labelDelta = new ArrayList<>();
-    private Map<String, Short> approvals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
-      this.projectState = projectState;
-      this.psId = psId;
-      this.in = in;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceConflictException, UnprocessableEntityException, IOException,
-            CommentsRejectedException {
-      user = ctx.getIdentifiedUser();
-      notes = ctx.getNotes();
-      ps = psUtil.get(ctx.getNotes(), psId);
-      List<RobotComment> newRobotComments =
-          in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
-      boolean dirty = false;
-      try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
-        dirty |= insertComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
-        dirty |= insertRobotComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
-        dirty |= updateLabels(projectState, ctx);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
-        dirty |= insertMessage(ctx);
-      }
-      return dirty;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-      NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-      if (notify.shouldNotify()) {
-        try {
-          email
-              .create(
-                  notify,
-                  notes,
-                  ps,
-                  user,
-                  mailMessage,
-                  ctx.getWhen(),
-                  comments,
-                  in.message,
-                  labelDelta,
-                  ctx.getRepoView())
-              .sendAsync();
-        } catch (IOException ex) {
-          throw new StorageException(
-              String.format("Repository %s not found", ctx.getProject().get()), ex);
-        }
-      }
-      String comment = mailMessage;
-      if (publishPatchSetLevelComment) {
-        // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
-        // added event. For backwards compatibility, patchset level comment has a higher priority
-        // than change message and should be used as comment in comment added event.
-        if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
-          List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
-          if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
-            CommentInput firstComment = patchSetLevelComments.get(0);
-            if (!Strings.isNullOrEmpty(firstComment.message)) {
-              comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
-            }
-          }
-        }
-      }
-      commentAdded.fire(
-          ctx.getChangeData(notes),
-          ps,
-          user.state(),
-          comment,
-          approvals,
-          oldApprovals,
-          ctx.getWhen());
-    }
-
-    /**
-     * Publishes draft and input comments. Input comments are those passed as input in the request
-     * body.
-     *
-     * @param ctx context for performing the change update.
-     * @param newRobotComments robot comments. Used only for validation in this method.
-     * @return true if any input comments where published.
-     */
-    private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws CommentsRejectedException {
-      Map<String, List<CommentInput>> inputComments = in.comments;
-      if (inputComments == null) {
-        inputComments = Collections.emptyMap();
-      }
-
-      // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
-      Map<String, HumanComment> drafts = new HashMap<>();
-
-      if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        drafts =
-            in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
-                ? changeDrafts(ctx)
-                : patchSetDrafts(ctx);
-      }
-
-      // Existing published comments
-      Set<CommentSetEntry> existingComments =
-          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
-
-      // Input comments should be deduplicated from existing drafts
-      List<HumanComment> inputCommentsToPublish =
-          resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
-
-      switch (in.drafts) {
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          Collection<HumanComment> filteredDrafts =
-              in.draftIdsToPublish == null
-                  ? drafts.values()
-                  : drafts.values().stream()
-                      .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
-                      .collect(Collectors.toList());
-
-          validateComments(
-              ctx,
-              Streams.concat(
-                  drafts.values().stream(),
-                  inputCommentsToPublish.stream(),
-                  newRobotComments.stream()));
-          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
-          comments.addAll(drafts.values());
-          break;
-        case KEEP:
-          validateComments(
-              ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
-          break;
-      }
-      commentsUtil.putHumanComments(
-          ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
-      comments.addAll(inputCommentsToPublish);
-      return !inputCommentsToPublish.isEmpty();
-    }
-
-    /**
-     * Returns the subset of {@code inputComments} that do not have a matching comment (with same
-     * id) neither in {@code existingComments} nor in {@code drafts}.
-     *
-     * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
-     * removed.
-     *
-     * @param inputComments new comments provided as {@link CommentInput} entries in the API.
-     * @param existingComments existing published comments in the database.
-     * @param drafts existing draft comments in the database. This map can be modified.
-     */
-    private List<HumanComment> resolveInputCommentsAndDrafts(
-        Map<String, List<CommentInput>> inputComments,
-        Set<CommentSetEntry> existingComments,
-        Map<String, HumanComment> drafts,
-        ChangeContext ctx) {
-      List<HumanComment> inputCommentsToPublish = new ArrayList<>();
-      for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
-        String path = entry.getKey();
-        for (CommentInput inputComment : entry.getValue()) {
-          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
-          if (comment == null) {
-            String parent = Url.decode(inputComment.inReplyTo);
-            comment =
-                commentsUtil.newHumanComment(
-                    ctx.getNotes(),
-                    ctx.getUser(),
-                    ctx.getWhen(),
-                    path,
-                    psId,
-                    inputComment.side(),
-                    inputComment.message,
-                    inputComment.unresolved,
-                    parent);
-          } else {
-            // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
-            comment.writtenOn = Timestamp.from(ctx.getWhen());
-            comment.side = inputComment.side();
-            comment.message = inputComment.message;
-          }
-
-          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
-          comment.setLineNbrAndRange(inputComment.line, inputComment.range);
-          comment.tag = in.tag;
-
-          if (existingComments.contains(CommentSetEntry.create(comment))) {
-            continue;
-          }
-          inputCommentsToPublish.add(comment);
-        }
-      }
-      return inputCommentsToPublish;
-    }
-
-    /**
-     * Validates all comments and the change message in a single call to fulfill the interface
-     * contract of {@link CommentValidator#validateComments(CommentValidationContext,
-     * ImmutableList)}.
-     */
-    private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
-        throws CommentsRejectedException {
-      CommentValidationContext commentValidationCtx =
-          CommentValidationContext.create(
-              ctx.getChange().getChangeId(),
-              ctx.getChange().getProject().get(),
-              ctx.getChange().getDest().branch());
-      String changeMessage = Strings.nullToEmpty(in.message).trim();
-      ImmutableList<CommentForValidation> draftsForValidation =
-          Stream.concat(
-                  comments.map(
-                      comment ->
-                          CommentForValidation.create(
-                              comment instanceof RobotComment
-                                  ? CommentForValidation.CommentSource.ROBOT
-                                  : CommentForValidation.CommentSource.HUMAN,
-                              comment.lineNbr > 0
-                                  ? CommentForValidation.CommentType.INLINE_COMMENT
-                                  : CommentForValidation.CommentType.FILE_COMMENT,
-                              comment.message,
-                              comment.getApproximateSize())),
-                  Stream.of(
-                      CommentForValidation.create(
-                          CommentForValidation.CommentSource.HUMAN,
-                          CommentForValidation.CommentType.CHANGE_MESSAGE,
-                          changeMessage,
-                          changeMessage.length())))
-              .collect(toImmutableList());
-      ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(
-              commentValidationCtx, commentValidators, draftsForValidation);
-      if (!draftValidationFailures.isEmpty()) {
-        throw new CommentsRejectedException(draftValidationFailures);
-      }
-    }
-
-    private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
-      if (in.robotComments == null) {
-        return false;
-      }
-      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
-      comments.addAll(newRobotComments);
-      return !newRobotComments.isEmpty();
-    }
-
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
-      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
-
-      Set<CommentSetEntry> existingIds =
-          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
-
-      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
-        String path = ent.getKey();
-        for (RobotCommentInput c : ent.getValue()) {
-          RobotComment e = createRobotCommentFromInput(ctx, path, c);
-          if (existingIds.contains(CommentSetEntry.create(e))) {
-            continue;
-          }
-          toAdd.add(e);
-        }
-      }
-      return toAdd;
-    }
-
-    private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
-      RobotComment robotComment =
-          commentsUtil.newRobotComment(
-              ctx,
-              path,
-              psId,
-              robotCommentInput.side(),
-              robotCommentInput.message,
-              robotCommentInput.robotId,
-              robotCommentInput.robotRunId);
-      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
-      robotComment.url = robotCommentInput.url;
-      robotComment.properties = robotCommentInput.properties;
-      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
-      robotComment.tag = in.tag;
-      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
-      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
-      return robotComment;
-    }
-
-    private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
-        List<FixSuggestionInfo> fixSuggestionInfos) {
-      if (fixSuggestionInfos == null) {
-        return ImmutableList.of();
-      }
-
-      ImmutableList.Builder<FixSuggestion> fixSuggestions =
-          ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
-      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
-      }
-      return fixSuggestions.build();
-    }
-
-    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
-      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
-      String fixId = ChangeUtil.messageUuid();
-      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
-    }
-
-    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
-      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
-    }
-
-    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
-      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
-      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
-    }
-
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
-      return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
-      Map<String, Short> labels = new HashMap<>();
-      for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.label(), psa.value());
-      }
-      return labels;
-    }
-
-    private Map<String, Short> getAllApprovals(
-        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
-      Map<String, Short> allApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        allApprovals.put(lt.getName(), (short) 0);
-      }
-      // set approvals to existing votes
-      if (current != null) {
-        allApprovals.putAll(current);
-      }
-      // set approvals to new votes
-      if (input != null) {
-        allApprovals.putAll(input);
-      }
-      return allApprovals;
-    }
-
-    private Map<String, Short> getPreviousApprovals(
-        Map<String, Short> allApprovals, Map<String, Short> current) {
-      Map<String, Short> previous = new HashMap<>();
-      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
-        // assume vote is 0 if there is no vote
-        if (!current.containsKey(approval.getKey())) {
-          previous.put(approval.getKey(), (short) 0);
-        } else {
-          previous.put(approval.getKey(), current.get(approval.getKey()));
-        }
-      }
-      return previous;
-    }
-
-    private boolean isReviewer(ChangeContext ctx) {
-      return approvalsUtil
-          .getReviewers(ctx.getNotes())
-          .byState(REVIEWER)
-          .contains(ctx.getAccountId());
-    }
-
-    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws ResourceConflictException {
-      Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
-
-      // If no labels were modified and change is closed, abort early.
-      // This avoids trying to record a modified label caused by a user
-      // losing access to a label after the change was submitted.
-      if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
-        return false;
-      }
-
-      List<PatchSetApproval> del = new ArrayList<>();
-      List<PatchSetApproval> ups = new ArrayList<>();
-      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, Short> allApprovals =
-          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
-      Map<String, Short> previous =
-          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
-
-      ChangeUpdate update = ctx.getUpdate(psId);
-      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
-        String name = ent.getKey();
-        LabelType lt =
-            labelTypes
-                .byLabel(name)
-                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
-
-        PatchSetApproval c = current.remove(lt.getName());
-        String normName = lt.getName();
-        approvals.put(normName, (short) 0);
-        if (ent.getValue() == null || ent.getValue() == 0) {
-          // User requested delete of this label.
-          oldApprovals.put(normName, null);
-          if (c != null) {
-            if (c.value() != 0) {
-              addLabelDelta(normName, (short) 0);
-              oldApprovals.put(normName, previous.get(normName));
-            }
-            del.add(c);
-            update.putApproval(normName, (short) 0);
-          }
-          // Only allow voting again if the vote is copied over from a past patch-set, or the
-          // values are different.
-        } else if (c != null
-            && (c.value() != ent.getValue() || isApprovalCopiedOver(c, ctx.getNotes()))) {
-          PatchSetApproval.Builder b =
-              c.toBuilder()
-                  .value(ent.getValue())
-                  .granted(ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag));
-          ctx.getUser().updateRealAccountId(b::realAccountId);
-          c = b.build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.value() == ent.getValue()) {
-          current.put(normName, c);
-          oldApprovals.put(normName, null);
-          approvals.put(normName, c.value());
-        } else if (c == null) {
-          c =
-              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putReviewer(user.getAccountId(), REVIEWER);
-          update.putApproval(normName, ent.getValue());
-        }
-      }
-
-      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
-
-      // Return early if user is not a reviewer and not posting any labels.
-      // This allows us to preserve their CC status.
-      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
-        return false;
-      }
-
-      return !del.isEmpty() || !ups.isEmpty();
-    }
-
-    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
-    private boolean isApprovalCopiedOver(
-        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
-      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
-          .anyMatch(p -> p.equals(patchSetApproval));
-    }
-
-    private void validatePostSubmitLabels(
-        ChangeContext ctx,
-        LabelTypes labelTypes,
-        Map<String, Short> previous,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del)
-        throws ResourceConflictException {
-      if (ctx.getChange().isNew()) {
-        return; // Not closed, nothing to validate.
-      } else if (del.isEmpty() && ups.isEmpty()) {
-        return; // No new votes.
-      } else if (!ctx.getChange().isMerged()) {
-        throw new ResourceConflictException("change is closed");
-      }
-
-      // Disallow reducing votes on any labels post-submit. This assumes the
-      // high values were broadly necessary to submit, so reducing them would
-      // make it possible to take a merged change and make it no longer
-      // submittable.
-      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
-      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
-
-      for (PatchSetApproval psa : del) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev != null && prev != 0) {
-          reduced.add(psa);
-        }
-      }
-
-      for (PatchSetApproval psa : ups) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev == null) {
-          continue;
-        }
-        if (prev > psa.value()) {
-          reduced.add(psa);
-        }
-        // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
-      }
-
-      if (!disallowed.isEmpty()) {
-        throw new ResourceConflictException(
-            "Voting on labels disallowed after submit: "
-                + disallowed.stream().distinct().sorted().collect(joining(", ")));
-      }
-      if (!reduced.isEmpty()) {
-        throw new ResourceConflictException(
-            "Cannot reduce vote on labels for closed change: "
-                + reduced.stream()
-                    .map(PatchSetApproval::label)
-                    .distinct()
-                    .sorted()
-                    .collect(joining(", ")));
-      }
-    }
-
-    private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, PatchSetApproval> current = new HashMap<>();
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
-        if (lt.isPresent()) {
-          current.put(lt.get().getName(), a);
-        } else {
-          del.add(a);
-        }
-      }
-      return current;
-    }
-
-    private boolean insertMessage(ChangeContext ctx) {
-      String msg = Strings.nullToEmpty(in.message).trim();
-
-      StringBuilder buf = new StringBuilder();
-      for (LabelVote d : labelDelta) {
-        buf.append(" ").append(d.format());
-      }
-      if (comments.size() == 1) {
-        buf.append("\n\n(1 comment)");
-      } else if (comments.size() > 1) {
-        buf.append(String.format("\n\n(%d comments)", comments.size()));
-      }
-      if (!msg.isEmpty()) {
-        // Message was already validated when validating comments, since validators need to see
-        // everything in a single call.
-        buf.append("\n\n").append(msg);
-      } else if (in.ready) {
-        buf.append("\n\n" + START_REVIEW_MESSAGE);
-      }
-
-      List<String> pluginMessages = new ArrayList<>();
-      onPostReviews.runEach(
-          onPostReview ->
-              onPostReview
-                  .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
-                  .ifPresent(
-                      pluginMessage ->
-                          pluginMessages.add(
-                              !pluginMessage.endsWith("\n")
-                                  ? pluginMessage + "\n"
-                                  : pluginMessage)));
-      if (!pluginMessages.isEmpty()) {
-        buf.append("\n\n");
-        buf.append(Joiner.on("\n").join(pluginMessages));
-      }
-
-      if (buf.length() == 0) {
-        return false;
-      }
-
-      mailMessage =
-          cmUtil.setChangeMessage(
-              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      return true;
-    }
-
-    private void addLabelDelta(String name, short value) {
-      labelDelta.add(LabelVote.create(name, value));
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
new file mode 100644
index 0000000..5ff0968
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -0,0 +1,758 @@
+// 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.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+public class PostReviewOp implements BatchUpdateOp {
+  interface Factory {
+    PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+  }
+
+  @VisibleForTesting
+  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final PublishCommentUtil publishCommentUtil;
+  private final PatchSetUtil psUtil;
+  private final EmailReviewComments.Factory email;
+  private final CommentAdded commentAdded;
+  private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
+
+  private final ProjectState projectState;
+  private final PatchSet.Id psId;
+  private final ReviewInput in;
+  private final boolean publishPatchSetLevelComment;
+
+  private IdentifiedUser user;
+  private ChangeNotes notes;
+  private PatchSet ps;
+  private String mailMessage;
+  private List<Comment> comments = new ArrayList<>();
+  private List<LabelVote> labelDelta = new ArrayList<>();
+  private Map<String, Short> approvals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  PostReviewOp(
+      @GerritServerConfig Config gerritConfig,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      PublishCommentUtil publishCommentUtil,
+      PatchSetUtil psUtil,
+      EmailReviewComments.Factory email,
+      CommentAdded commentAdded,
+      PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
+      @Assisted ProjectState projectState,
+      @Assisted PatchSet.Id psId,
+      @Assisted ReviewInput in) {
+    this.approvalsUtil = approvalsUtil;
+    this.publishCommentUtil = publishCommentUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.commentsUtil = commentsUtil;
+    this.email = email;
+    this.commentAdded = commentAdded;
+    this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
+    this.publishPatchSetLevelComment =
+        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
+
+    this.projectState = projectState;
+    this.psId = psId;
+    this.in = in;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, UnprocessableEntityException, IOException,
+          CommentsRejectedException {
+    user = ctx.getIdentifiedUser();
+    notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getNotes(), psId);
+    List<RobotComment> newRobotComments =
+        in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+    boolean dirty = false;
+    try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
+      dirty |= insertComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
+      dirty |= insertRobotComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
+      dirty |= updateLabels(projectState, ctx);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
+      dirty |= insertMessage(ctx);
+    }
+    return dirty;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+    NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
+    if (notify.shouldNotify()) {
+      email
+          .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
+          .sendAsync();
+    }
+    String comment = mailMessage;
+    if (publishPatchSetLevelComment) {
+      // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
+      // added event. For backwards compatibility, patchset level comment has a higher priority
+      // than change message and should be used as comment in comment added event.
+      if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
+        List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
+        if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
+          CommentInput firstComment = patchSetLevelComments.get(0);
+          if (!Strings.isNullOrEmpty(firstComment.message)) {
+            comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
+          }
+        }
+      }
+    }
+    commentAdded.fire(
+        ctx.getChangeData(notes),
+        ps,
+        user.state(),
+        comment,
+        approvals,
+        oldApprovals,
+        ctx.getWhen());
+  }
+
+  /**
+   * Publishes draft and input comments. Input comments are those passed as input in the request
+   * body.
+   *
+   * @param ctx context for performing the change update.
+   * @param newRobotComments robot comments. Used only for validation in this method.
+   * @return true if any input comments where published.
+   */
+  private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
+      throws CommentsRejectedException {
+    Map<String, List<CommentInput>> inputComments = in.comments;
+    if (inputComments == null) {
+      inputComments = Collections.emptyMap();
+    }
+
+    // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
+    Map<String, HumanComment> drafts = new HashMap<>();
+
+    if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
+      drafts =
+          in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+              ? changeDrafts(ctx)
+              : patchSetDrafts(ctx);
+    }
+
+    // Existing published comments
+    Set<CommentSetEntry> existingComments =
+        in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
+
+    // Input comments should be deduplicated from existing drafts
+    List<HumanComment> inputCommentsToPublish =
+        resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+    switch (in.drafts) {
+      case PUBLISH:
+      case PUBLISH_ALL_REVISIONS:
+        Collection<HumanComment> filteredDrafts =
+            in.draftIdsToPublish == null
+                ? drafts.values()
+                : drafts.values().stream()
+                    .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+                    .collect(Collectors.toList());
+
+        validateComments(
+            ctx,
+            Streams.concat(
+                drafts.values().stream(),
+                inputCommentsToPublish.stream(),
+                newRobotComments.stream()));
+        publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+        comments.addAll(drafts.values());
+        break;
+      case KEEP:
+        validateComments(
+            ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+        break;
+    }
+    commentsUtil.putHumanComments(
+        ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+    comments.addAll(inputCommentsToPublish);
+    return !inputCommentsToPublish.isEmpty();
+  }
+
+  /**
+   * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
+   * neither in {@code existingComments} nor in {@code drafts}.
+   *
+   * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+   * removed.
+   *
+   * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+   * @param existingComments existing published comments in the database.
+   * @param drafts existing draft comments in the database. This map can be modified.
+   */
+  private List<HumanComment> resolveInputCommentsAndDrafts(
+      Map<String, List<CommentInput>> inputComments,
+      Set<CommentSetEntry> existingComments,
+      Map<String, HumanComment> drafts,
+      ChangeContext ctx) {
+    List<HumanComment> inputCommentsToPublish = new ArrayList<>();
+    for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+      String path = entry.getKey();
+      for (CommentInput inputComment : entry.getValue()) {
+        HumanComment comment = drafts.remove(Url.decode(inputComment.id));
+        if (comment == null) {
+          String parent = Url.decode(inputComment.inReplyTo);
+          comment =
+              commentsUtil.newHumanComment(
+                  ctx.getNotes(),
+                  ctx.getUser(),
+                  ctx.getWhen(),
+                  path,
+                  psId,
+                  inputComment.side(),
+                  inputComment.message,
+                  inputComment.unresolved,
+                  parent);
+        } else {
+          // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+          comment.writtenOn = Timestamp.from(ctx.getWhen());
+          comment.side = inputComment.side();
+          comment.message = inputComment.message;
+        }
+
+        commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+        comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+        comment.tag = in.tag;
+
+        if (existingComments.contains(CommentSetEntry.create(comment))) {
+          continue;
+        }
+        inputCommentsToPublish.add(comment);
+      }
+    }
+    return inputCommentsToPublish;
+  }
+
+  /**
+   * Validates all comments and the change message in a single call to fulfill the interface
+   * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
+   */
+  private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
+      throws CommentsRejectedException {
+    CommentValidationContext commentValidationCtx =
+        CommentValidationContext.create(
+            ctx.getChange().getChangeId(),
+            ctx.getChange().getProject().get(),
+            ctx.getChange().getDest().branch());
+    String changeMessage = Strings.nullToEmpty(in.message).trim();
+    ImmutableList<CommentForValidation> draftsForValidation =
+        Stream.concat(
+                comments.map(
+                    comment ->
+                        CommentForValidation.create(
+                            comment instanceof RobotComment
+                                ? CommentForValidation.CommentSource.ROBOT
+                                : CommentForValidation.CommentSource.HUMAN,
+                            comment.lineNbr > 0
+                                ? CommentForValidation.CommentType.INLINE_COMMENT
+                                : CommentForValidation.CommentType.FILE_COMMENT,
+                            comment.message,
+                            comment.getApproximateSize())),
+                Stream.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentSource.HUMAN,
+                        CommentForValidation.CommentType.CHANGE_MESSAGE,
+                        changeMessage,
+                        changeMessage.length())))
+            .collect(toImmutableList());
+    ImmutableList<CommentValidationFailure> draftValidationFailures =
+        PublishCommentUtil.findInvalidComments(
+            commentValidationCtx, commentValidators, draftsForValidation);
+    if (!draftValidationFailures.isEmpty()) {
+      throw new CommentsRejectedException(draftValidationFailures);
+    }
+  }
+
+  private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
+    if (in.robotComments == null) {
+      return false;
+    }
+    commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+    comments.addAll(newRobotComments);
+    return !newRobotComments.isEmpty();
+  }
+
+  private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
+    List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+    Set<CommentSetEntry> existingIds =
+        in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+    for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+      String path = ent.getKey();
+      for (RobotCommentInput c : ent.getValue()) {
+        RobotComment e = createRobotCommentFromInput(ctx, path, c);
+        if (existingIds.contains(CommentSetEntry.create(e))) {
+          continue;
+        }
+        toAdd.add(e);
+      }
+    }
+    return toAdd;
+  }
+
+  private RobotComment createRobotCommentFromInput(
+      ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
+    RobotComment robotComment =
+        commentsUtil.newRobotComment(
+            ctx,
+            path,
+            psId,
+            robotCommentInput.side(),
+            robotCommentInput.message,
+            robotCommentInput.robotId,
+            robotCommentInput.robotRunId);
+    robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+    robotComment.url = robotCommentInput.url;
+    robotComment.properties = robotCommentInput.properties;
+    robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+    robotComment.tag = in.tag;
+    commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
+    robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+    return robotComment;
+  }
+
+  private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
+      List<FixSuggestionInfo> fixSuggestionInfos) {
+    if (fixSuggestionInfos == null) {
+      return ImmutableList.of();
+    }
+
+    ImmutableList.Builder<FixSuggestion> fixSuggestions =
+        ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+    }
+    return fixSuggestions.build();
+  }
+
+  private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+    List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+    String fixId = ChangeUtil.messageUuid();
+    return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+  }
+
+  private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+    return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+  }
+
+  private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+    Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+    return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+  }
+
+  private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
+    return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
+    return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
+    Map<String, Short> labels = new HashMap<>();
+    for (PatchSetApproval psa : patchsetApprovals) {
+      labels.put(psa.label(), psa.value());
+    }
+    return labels;
+  }
+
+  private Map<String, Short> getAllApprovals(
+      LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
+    Map<String, Short> allApprovals = new HashMap<>();
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      allApprovals.put(lt.getName(), (short) 0);
+    }
+    // set approvals to existing votes
+    if (current != null) {
+      allApprovals.putAll(current);
+    }
+    // set approvals to new votes
+    if (input != null) {
+      allApprovals.putAll(input);
+    }
+    return allApprovals;
+  }
+
+  private Map<String, Short> getPreviousApprovals(
+      Map<String, Short> allApprovals, Map<String, Short> current) {
+    Map<String, Short> previous = new HashMap<>();
+    for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+      // assume vote is 0 if there is no vote
+      if (!current.containsKey(approval.getKey())) {
+        previous.put(approval.getKey(), (short) 0);
+      } else {
+        previous.put(approval.getKey(), current.get(approval.getKey()));
+      }
+    }
+    return previous;
+  }
+
+  private boolean isReviewer(ChangeContext ctx) {
+    return approvalsUtil
+        .getReviewers(ctx.getNotes())
+        .byState(REVIEWER)
+        .contains(ctx.getAccountId());
+  }
+
+  private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+      throws ResourceConflictException {
+    Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
+
+    // If no labels were modified and change is closed, abort early.
+    // This avoids trying to record a modified label caused by a user
+    // losing access to a label after the change was submitted.
+    if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
+      return false;
+    }
+
+    List<PatchSetApproval> del = new ArrayList<>();
+    List<PatchSetApproval> ups = new ArrayList<>();
+    Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, Short> allApprovals =
+        getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+    Map<String, Short> previous =
+        getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+      String name = ent.getKey();
+      LabelType lt =
+          labelTypes
+              .byLabel(name)
+              .orElseThrow(() -> new IllegalStateException("no label config for " + name));
+
+      PatchSetApproval c = current.remove(lt.getName());
+      String normName = lt.getName();
+      approvals.put(normName, (short) 0);
+      if (ent.getValue() == null || ent.getValue() == 0) {
+        // User requested delete of this label.
+        oldApprovals.put(normName, null);
+        if (c != null) {
+          if (c.value() != 0) {
+            addLabelDelta(normName, (short) 0);
+            oldApprovals.put(normName, previous.get(normName));
+          }
+          del.add(c);
+          update.putApproval(normName, (short) 0);
+        }
+        // Only allow voting again if the vote is copied over from a past patch-set, or the
+        // values are different.
+      } else if (c != null
+          && (c.value() != ent.getValue()
+              || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
+        PatchSetApproval.Builder b =
+            c.toBuilder()
+                .value(ent.getValue())
+                .granted(ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag));
+        ctx.getUser().updateRealAccountId(b::realAccountId);
+        c = b.build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putApproval(normName, ent.getValue());
+      } else if (c != null && c.value() == ent.getValue()) {
+        current.put(normName, c);
+        oldApprovals.put(normName, null);
+        approvals.put(normName, c.value());
+      } else if (c == null) {
+        c =
+            ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag))
+                .granted(ctx.getWhen())
+                .build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putReviewer(user.getAccountId(), REVIEWER);
+        update.putApproval(normName, ent.getValue());
+      }
+    }
+
+    validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+    // Return early if user is not a reviewer and not posting any labels.
+    // This allows us to preserve their CC status.
+    if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+      return false;
+    }
+
+    return !del.isEmpty() || !ups.isEmpty();
+  }
+
+  /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
+  private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+    return !changeNotes.getApprovals().onlyNonCopied()
+        .get(changeNotes.getChange().currentPatchSetId()).stream()
+        .anyMatch(p -> p.equals(patchSetApproval));
+  }
+
+  private void validatePostSubmitLabels(
+      ChangeContext ctx,
+      LabelTypes labelTypes,
+      Map<String, Short> previous,
+      List<PatchSetApproval> ups,
+      List<PatchSetApproval> del)
+      throws ResourceConflictException {
+    if (ctx.getChange().isNew()) {
+      return; // Not closed, nothing to validate.
+    } else if (del.isEmpty() && ups.isEmpty()) {
+      return; // No new votes.
+    } else if (!ctx.getChange().isMerged()) {
+      throw new ResourceConflictException("change is closed");
+    }
+
+    // Disallow reducing votes on any labels post-submit. This assumes the
+    // high values were broadly necessary to submit, so reducing them would
+    // make it possible to take a merged change and make it no longer
+    // submittable.
+    List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+    List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+    for (PatchSetApproval psa : del) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev != null && prev != 0) {
+        reduced.add(psa);
+      }
+    }
+
+    for (PatchSetApproval psa : ups) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev == null) {
+        continue;
+      }
+      if (prev > psa.value()) {
+        reduced.add(psa);
+      }
+      // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
+    }
+
+    if (!disallowed.isEmpty()) {
+      throw new ResourceConflictException(
+          "Voting on labels disallowed after submit: "
+              + disallowed.stream().distinct().sorted().collect(joining(", ")));
+    }
+    if (!reduced.isEmpty()) {
+      throw new ResourceConflictException(
+          "Cannot reduce vote on labels for closed change: "
+              + reduced.stream()
+                  .map(PatchSetApproval::label)
+                  .distinct()
+                  .sorted()
+                  .collect(joining(", ")));
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(
+      ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, PatchSetApproval> current = new HashMap<>();
+
+    for (PatchSetApproval a :
+        approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
+      if (a.isLegacySubmit()) {
+        continue;
+      }
+
+      Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+      if (lt.isPresent()) {
+        current.put(lt.get().getName(), a);
+      } else {
+        del.add(a);
+      }
+    }
+    return current;
+  }
+
+  private boolean insertMessage(ChangeContext ctx) {
+    String msg = Strings.nullToEmpty(in.message).trim();
+
+    StringBuilder buf = new StringBuilder();
+    for (LabelVote d : labelDelta) {
+      buf.append(" ").append(d.format());
+    }
+    if (comments.size() == 1) {
+      buf.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      buf.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (!msg.isEmpty()) {
+      // Message was already validated when validating comments, since validators need to see
+      // everything in a single call.
+      buf.append("\n\n").append(msg);
+    } else if (in.ready) {
+      buf.append("\n\n" + START_REVIEW_MESSAGE);
+    }
+
+    List<String> pluginMessages = new ArrayList<>();
+    onPostReviews.runEach(
+        onPostReview ->
+            onPostReview
+                .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                .ifPresent(
+                    pluginMessage ->
+                        pluginMessages.add(
+                            !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
+    if (!pluginMessages.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(Joiner.on("\n").join(pluginMessages));
+    }
+
+    if (buf.length() == 0) {
+      return false;
+    }
+
+    mailMessage =
+        cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
+    return true;
+  }
+
+  private void addLabelDelta(String name, short value) {
+    labelDelta.add(LabelVote.create(name, value));
+  }
+
+  private TraceContext.TraceTimer newTimer(String method) {
+    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 91fa2f0..2c15bc9 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.change.ChangeJson;
@@ -95,7 +96,9 @@
     this.start = start;
   }
 
-  @Option(name = "--no-limit", usage = "Return all results, overriding the default limit")
+  @Option(
+      name = "--no-limit",
+      usage = "Return all results, overriding the default limit. Ignored for anonymous users.")
   public void setNoLimit(boolean on) {
     this.noLimit = on;
   }
@@ -168,7 +171,7 @@
     if (start != null) {
       queryProcessor.setStart(start);
     }
-    if (noLimit != null) {
+    if (noLimit != null && !AnonymousUser.class.isAssignableFrom(userProvider.get().getClass())) {
       queryProcessor.setNoLimit(noLimit);
     }
     if (skipVisibility != null) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 835fd5a..5e30dae 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -219,7 +219,9 @@
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Rebase")
-            .setTitle("Rebase onto tip of branch or parent change")
+            .setTitle(
+                "Rebase onto tip of branch or parent change. Makes you the uploader of this "
+                    + "change which can affect validity of approvals.")
             .setVisible(false);
 
     Change change = rsrc.getChange();
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 49286fc..3d9d588 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -284,9 +284,8 @@
    */
   private void addToAttentionSet(
       BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
-    AddToAttentionSetOp addOwnerToAttentionSet =
-        addToAttentionSetOpFactory.create(user, reason, notify);
-    bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
+    AddToAttentionSetOp addToAttentionSet = addToAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index e3cf4db..842f4b9 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.FieldBundle;
@@ -236,10 +235,7 @@
     return suggestedReviewers;
   }
 
-  private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
-    if (useLegacyNumericFields) {
-      return Account.id(f.getValue(AccountField.ID).intValue());
-    }
+  private static Account.Id fromIdField(FieldBundle f) {
     return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
   }
 
@@ -255,10 +251,6 @@
               accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
       logger.atFine().log("accounts index query: %s", pred);
       accountIndexRewriter.validateMaxTermsInQuery(pred);
-      boolean useLegacyNumericFields =
-          accountIndexes.getSearchIndex().getSchema().useLegacyNumericFields();
-      FieldDef<AccountState, ?> idField =
-          useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
       ResultSet<FieldBundle> result =
           accountIndexes
               .getSearchIndex()
@@ -268,12 +260,10 @@
                       indexConfig,
                       0,
                       suggestReviewers.getLimit(),
-                      ImmutableSet.of(idField.getName())))
+                      ImmutableSet.of(AccountField.ID_STR.getName())))
               .readRaw();
       List<Account.Id> matches =
-          result.toList().stream()
-              .map(f -> fromIdField(f, useLegacyNumericFields))
-              .collect(toList());
+          result.toList().stream().map(f -> fromIdField(f)).collect(toList());
       logger.atFine().log("Matches: %s", matches);
       return matches;
     } catch (TooManyTermsInQueryException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 4782729..9597dde 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -265,7 +265,11 @@
           // those may still be stale.
           MergeOp.checkSubmitRequirements(cd.getId().equals(c.getId()) ? cd : c);
         } catch (ResourceConflictException e) {
-          return "Change " + c.getId() + " is not ready: " + e.getMessage();
+          return (c.getId() == cd.getId())
+              ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
+              : String.format(
+                  "Change %s must be submitted with change %s but %s is not ready: %s",
+                  cd.getId(), c.getId(), c.getId(), e.getMessage());
         }
       }
 
diff --git a/java/com/google/gerrit/server/restapi/config/IndexChanges.java b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
index 904c44f..caca5bc 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -27,6 +28,8 @@
 import com.google.gerrit.server.restapi.config.IndexChanges.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
@@ -36,6 +39,7 @@
 
   public static class Input {
     public Set<String> changes;
+    boolean deleteMissing;
   }
 
   private final ChangeFinder changeFinder;
@@ -57,7 +61,21 @@
     }
 
     for (String id : input.changes) {
-      for (ChangeNotes n : changeFinder.find(id)) {
+      List<ChangeNotes> notes = changeFinder.find(id);
+
+      if (notes.isEmpty()) {
+        logger.atWarning().log("Change %s missing in NoteDb", id);
+        if (input.deleteMissing) {
+          Optional<Change.Id> changeId = Change.Id.tryParse(id);
+          if (changeId.isPresent()) {
+            logger.atWarning().log("Deleting change %s from index", changeId.get());
+            indexer.delete(changeId.get());
+          }
+        }
+        continue;
+      }
+
+      for (ChangeNotes n : notes) {
         indexer.index(changeDataFactory.create(n));
         logger.atFine().log("Indexed change %s", id);
       }
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index ddc3fca..cab5b45 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -98,6 +98,7 @@
   private final PrologOptions opts;
   private Term submitRule;
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private PrologRuleEvaluator(
       AccountCache accountCache,
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index daa24d8..60dbc6c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.notedb.Sequences;
 import java.util.Optional;
@@ -52,10 +53,14 @@
                 LabelValue.create((short) 2, "Looks good to me, approved"),
                 LabelValue.create((short) 1, "Looks good to me, but someone else must approve"),
                 LabelValue.create((short) 0, "No score"),
-                LabelValue.create((short) -1, "I would prefer this is not merged as is"),
-                LabelValue.create((short) -2, "This shall not be merged")))
-        .setCopyMinScore(true)
-        .setCopyAllScoresOnTrivialRebase(true)
+                LabelValue.create((short) -1, "I would prefer this is not submitted as is"),
+                LabelValue.create((short) -2, "This shall not be submitted")))
+        // override the default which is true and rely on the copy condition instead
+        .setCopyAllScoresIfNoChange(false)
+        .setCopyCondition(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()))
         .build();
   }
 
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
new file mode 100644
index 0000000..7d49b97
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -0,0 +1,305 @@
+// 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.schema;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Migrates all label configurations of a project to copy conditions.
+ *
+ * <p>The label configuration in {@code project.config} controls under which conditions approvals
+ * should be copied to new patch sets:
+ *
+ * <ul>
+ *   <li>old way: by setting boolean flags and copy values (fields {@code copyAnyScore}, {@code
+ *       copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ *   <li>new way: by setting a query as a copy condition (field {@code copyCondition})
+ * </ul>
+ *
+ * <p>This class updates all label configurations in the {@code project.config} of the given
+ * project:
+ *
+ * <ul>
+ *   <li>it stores the conditions under which approvals should be copied to new patchs as a copy
+ *       condition query (field {@code copyCondition})
+ *   <li>it unsets all deprecated fields to control approval copying (fields {@code copyAnyScore},
+ *       {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ * </ul>
+ */
+public class MigrateLabelConfigToCopyCondition {
+  public static final String COMMIT_MESSAGE = "Migrate label configs to copy conditions";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  @Inject
+  public MigrateLabelConfigToCopyCondition(
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  /**
+   * Executes the migration for the given project.
+   *
+   * @param projectName the name of the project for which the migration should be executed
+   * @throws IOException thrown if an IO error occurs
+   * @throws ConfigInvalidException thrown if the existing project.config is invalid and cannot be
+   *     parsed
+   */
+  public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare projectConfig =
+        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    try (Repository repo = repoManager.openRepository(projectName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
+      boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
+
+      projectConfig.load(projectName, repo);
+
+      Config cfg = projectConfig.getConfig();
+      String orgConfigAsText = cfg.toText();
+      for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+        String newCopyCondition = computeCopyCondition(isAlreadyMigrated, cfg, labelName);
+        if (!Strings.isNullOrEmpty(newCopyCondition)) {
+          cfg.setString(
+              ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, newCopyCondition);
+        }
+
+        unsetDeprecatedFields(cfg, labelName);
+      }
+
+      if (cfg.toText().equals(orgConfigAsText)) {
+        // Config was not changed (ie. none of the label definitions had any deprecated field set).
+        return;
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MESSAGE + "\n");
+      projectConfig.commit(md);
+    }
+  }
+
+  private static String computeCopyCondition(
+      boolean isAlreadyMigrated, Config cfg, String labelName) {
+    List<String> copyConditions = new ArrayList<>();
+
+    ifTrue(cfg, labelName, ProjectConfig.KEY_COPY_ANY_SCORE, () -> copyConditions.add("is:ANY"));
+    ifTrue(cfg, labelName, ProjectConfig.KEY_COPY_MIN_SCORE, () -> copyConditions.add("is:MIN"));
+    ifTrue(cfg, labelName, ProjectConfig.KEY_COPY_MAX_SCORE, () -> copyConditions.add("is:MAX"));
+    forEachSkipNullValues(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_VALUE,
+        value -> copyConditions.add("is:" + quoteIfNegative(parseCopyValue(value))));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+
+    // If the migration has already been run on this project we must no longer assume true as
+    // default value when copyAllScoresIfNoChange is unset. This is to ensure that the migration is
+    // idempotent when copyAllScoresIfNoChange is set to false:
+    //
+    // 1. migration run:
+    // Removes the copyAllScoresIfNoChange flag, but doesn't add anything to the copy condition.
+    //
+    // 2. migration run:
+    // Since the copyAllScoresIfNoChange flag is no longer set, we would wrongly assume true now and
+    // wrongly include "changekind:NO_CHANGE" into the copy condition. To prevent this we assume
+    // false as default for copyAllScoresIfNoChange once the migration has been run, so that the 2.
+    // migration run is a no-op.
+    if (!isAlreadyMigrated) {
+      // The default value for copyAllScoresIfNoChange is true, hence if this parameter is not set
+      // we need to include "changekind:NO_CHANGE" into the copy condition.
+      ifUnset(
+          cfg,
+          labelName,
+          ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+          () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+    }
+
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        () -> copyConditions.add("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        () -> copyConditions.add("changekind:" + ChangeKind.TRIVIAL_REBASE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        () -> copyConditions.add("has:unchanged-files"));
+
+    if (copyConditions.isEmpty()) {
+      // No copy conditions need to be added. Simply return the current copy condition as it is.
+      // Returning here prevents that OR conditions are reordered and that parentheses are added
+      // unnecessarily.
+      return cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+    }
+
+    ifSet(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_CONDITION,
+        copyCondition -> copyConditions.addAll(splitOrConditions(copyCondition)));
+
+    return copyConditions.stream()
+        .map(MigrateLabelConfigToCopyCondition::encloseInParenthesesIfNeeded)
+        .sorted()
+        // Remove duplicated OR conditions
+        .distinct()
+        .collect(joining(" OR "));
+  }
+
+  private static void ifSet(Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key)).ifPresent(consumer);
+  }
+
+  private static void ifUnset(Config cfg, String labelName, String key, Runnable runnable) {
+    Optional<String> value =
+        Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key));
+    if (!value.isPresent()) {
+      runnable.run();
+    }
+  }
+
+  private static void ifTrue(Config cfg, String labelName, String key, Runnable runnable) {
+    if (cfg.getBoolean(ProjectConfig.LABEL, labelName, key, /* defaultValue= */ false)) {
+      runnable.run();
+    }
+  }
+
+  private static void forEachSkipNullValues(
+      Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Arrays.stream(cfg.getStringList(ProjectConfig.LABEL, labelName, key))
+        .filter(Objects::nonNull)
+        .forEach(consumer);
+  }
+
+  private static void unsetDeprecatedFields(Config cfg, String labelName) {
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_ANY_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_MIN_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_MAX_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_VALUE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    cfg.unset(
+        ProjectConfig.LABEL,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    cfg.unset(
+        ProjectConfig.LABEL,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+  }
+
+  private static ImmutableList<String> splitOrConditions(String copyCondition) {
+    if (copyCondition.contains("(") || copyCondition.contains(")")) {
+      // cannot parse complex predicate tree
+      return ImmutableList.of(copyCondition);
+    }
+
+    // split query on OR, this way we can detect and remove duplicate OR conditions later
+    return ImmutableList.copyOf(Splitter.on(" OR ").splitToList(copyCondition));
+  }
+
+  /**
+   * Add parentheses around the given copyCondition if it consists out of 2 or more predicates and
+   * if it isn't enclosed in parentheses yet.
+   */
+  private static String encloseInParenthesesIfNeeded(String copyCondition) {
+    if (copyCondition.contains(" ")
+        && !(copyCondition.startsWith("(") && copyCondition.endsWith(")"))) {
+      return "(" + copyCondition + ")";
+    }
+    return copyCondition;
+  }
+
+  private static short parseCopyValue(String value) {
+    return Shorts.checkedCast(PermissionRule.parseInt(value));
+  }
+
+  private static String quoteIfNegative(short value) {
+    if (value < 0) {
+      return "\"" + value + "\"";
+    }
+    return Integer.toString(value);
+  }
+
+  public static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
+        return false;
+      }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MESSAGE.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 39e3a59..1516fd5 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -102,10 +102,10 @@
           "[label \"Code-Review\"]",
           "  function = MaxWithBlock",
           "  defaultValue = 0",
-          "  copyMinScore = true",
-          "  copyAllScoresOnTrivialRebase = true",
-          "  value = -2 This shall not be merged",
-          "  value = -1 I would prefer this is not merged as is",
+          "  copyAllScoresIfNoChange = false",
+          "  copyCondition = changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
+          "  value = -2 This shall not be submitted",
+          "  value = -1 I would prefer this is not submitted as is",
           "  value = 0 No score",
           "  value = +1 Looks good to me, but someone else must approve",
           "  value = +2 Looks good to me, approved");
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 0680d5e..1a49171 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -256,7 +256,7 @@
   private CommitStatus commitStatus;
   private SubmitInput submitInput;
   private NotifyResolver.Result notify;
-  private Set<Project.NameKey> allProjects;
+  private Set<Project.NameKey> projects;
   private boolean dryrun;
   private TopicMetrics topicMetrics;
 
@@ -526,7 +526,8 @@
                     logger.atFine().log("Bypassing submit rules");
                     bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
                   }
-                  integrateIntoHistory(filteredNoteDbChangeSet, submissionExecutor);
+                  integrateIntoHistory(
+                      filteredNoteDbChangeSet, submissionExecutor, checkSubmitRules);
                   return null;
                 })
             .listener(retryTracker)
@@ -604,7 +605,8 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs, SubmissionExecutor submissionExecutor)
+  private void integrateIntoHistory(
+      ChangeSet cs, SubmissionExecutor submissionExecutor, boolean checkSubmitRules)
       throws RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logger.atFine().log("Beginning merge attempt on %s", cs);
@@ -635,8 +637,10 @@
       List<SubmitStrategy> strategies =
           getSubmitStrategies(
               toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
-      this.allProjects = updateOrderCalculator.getProjectsInOrder();
-      List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
+      this.projects = updateOrderCalculator.getProjectsInOrder();
+      List<BatchUpdate> batchUpdates =
+          orm.batchUpdates(
+              projects, /* refLogMessage= */ checkSubmitRules ? "merged" : "forced-merge");
       // Group batch updates by project
       Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
           batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
@@ -663,7 +667,7 @@
 
         // Do not leave executed BatchUpdates in the OpenRepos
         if (!dryrun) {
-          orm.resetUpdates(ImmutableSet.copyOf(this.allProjects));
+          orm.resetUpdates(ImmutableSet.copyOf(this.projects));
         }
       }
     } catch (NoSuchProjectException e) {
@@ -697,7 +701,7 @@
   }
 
   public Set<Project.NameKey> getAllProjects() {
-    return allProjects;
+    return projects;
   }
 
   public MergeOpRepoManager getMergeOpRepoManager() {
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 2024448..0a74a07 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -206,11 +206,12 @@
     }
   }
 
-  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects)
+  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects, String refLogMessage)
       throws NoSuchProjectException, IOException {
+    requireNonNull(refLogMessage, "refLogMessage");
     List<BatchUpdate> updates = new ArrayList<>(projects.size());
     for (Project.NameKey project : projects) {
-      updates.add(getRepo(project).getUpdate().setNotify(notify).setRefLogMessage("merged"));
+      updates.add(getRepo(project).getUpdate().setNotify(notify).setRefLogMessage(refLogMessage));
     }
     return updates;
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index cee0ad9..a3bb58b 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -96,13 +97,13 @@
   }
 
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   SubmitDryRun(
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       Provider<InternalChangeQuery> queryProvider) {
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 83c6634..f638078 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -151,7 +152,7 @@
         EmailMerge.Factory mergedSenderFactory,
         GitRepositoryManager repoManager,
         LabelNormalizer labelNormalizer,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         PatchSetInfoFactory patchSetInfoFactory,
         PatchSetUtil psUtil,
         @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 69d76e2..ba736fa 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -103,7 +103,10 @@
           }
         }
       }
-      BatchUpdate.execute(orm.batchUpdates(superProjects), ImmutableList.of(), dryrun);
+      BatchUpdate.execute(
+          orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
+          ImmutableList.of(),
+          dryrun);
     } catch (UpdateException | IOException | NoSuchProjectException e) {
       throw new StorageException("Cannot update gitlinks", e);
     }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index f26882a..b247552 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -354,6 +354,12 @@
     }
 
     @Override
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+
+    @Override
     public ChangeData getChangeData(Change change) {
       return changeDatas.computeIfAbsent(change.getId(), id -> changeDataFactory.create(change));
     }
diff --git a/java/com/google/gerrit/server/update/PostUpdateContext.java b/java/com/google/gerrit/server/update/PostUpdateContext.java
index d4d1f62..25af264 100644
--- a/java/com/google/gerrit/server/update/PostUpdateContext.java
+++ b/java/com/google/gerrit/server/update/PostUpdateContext.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 
@@ -27,9 +28,13 @@
    * an update or because this method has been invoked before, the cached change data instance is
    * returned.
    *
-   * @param change the change for which the change data should be returned
+   * @param changeId the ID of the change for which the change data should be returned
    */
-  ChangeData getChangeData(Change change);
+  ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId);
+
+  default ChangeData getChangeData(Change change) {
+    return getChangeData(change.getProject(), change.getId());
+  }
 
   default ChangeData getChangeData(ChangeNotes changeNotes) {
     return getChangeData(changeNotes.getChange());
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 48ddd31..1b36139 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -18,19 +18,24 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.AttentionSetSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
-public class AttentionSetEmail implements Runnable, RequestContext {
+public class AttentionSetEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -43,7 +48,6 @@
      * @param ctx context for sending the email.
      * @param change the change that the user was added/removed in.
      * @param reason reason for adding/removing the user.
-     * @param messageId messageId for tracking the email.
      * @param attentionUserId the user added/removed.
      */
     AttentionSetEmail create(
@@ -51,70 +55,117 @@
         Context ctx,
         Change change,
         String reason,
-        MessageIdGenerator.MessageId messageId,
         Account.Id attentionUserId);
   }
 
-  private ExecutorService sendEmailsExecutor;
-  private AccountTemplateUtil accountTemplateUtil;
-  private AttentionSetSender sender;
-  private Context ctx;
-  private Change change;
-  private String reason;
-
-  private MessageIdGenerator.MessageId messageId;
-  private Account.Id attentionUserId;
+  private final ExecutorService sendEmailsExecutor;
+  private final AsyncSender asyncSender;
 
   @Inject
   AttentionSetEmail(
       @SendEmailExecutor ExecutorService executor,
+      ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       AccountTemplateUtil accountTemplateUtil,
       @Assisted AttentionSetSender sender,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
-      @Assisted MessageIdGenerator.MessageId messageId,
       @Assisted Account.Id attentionUserId) {
     this.sendEmailsExecutor = executor;
-    this.accountTemplateUtil = accountTemplateUtil;
-    this.sender = sender;
-    this.ctx = ctx;
-    this.change = change;
-    this.reason = reason;
-    this.messageId = messageId;
-    this.attentionUserId = attentionUserId;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              ctx.getRepoView(), change.currentPatchSetId(), "AttentionSetEmail");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            ctx.getIdentifiedUser(),
+            sender,
+            messageId,
+            ctx.getNotify(change.getId()),
+            attentionUserId,
+            accountTemplateUtil.replaceTemplates(reason),
+            change.getId());
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    try {
-      AccountState accountState =
-          ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
-      if (accountState != null) {
-        sender.setFrom(accountState.account().id());
-      }
-      sender.setNotify(ctx.getNotify(change.getId()));
-      sender.setAttentionSetUser(attentionUserId);
-      sender.setReason(accountTemplateUtil.replaceTemplates(reason));
-      sender.setMessageId(messageId);
-      sender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final IdentifiedUser user;
+    private final AttentionSetSender sender;
+    private final MessageIdGenerator.MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Account.Id attentionUserId;
+    private final String reason;
+    private final Change.Id changeId;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        IdentifiedUser user,
+        AttentionSetSender sender,
+        MessageIdGenerator.MessageId messageId,
+        NotifyResolver.Result notify,
+        Account.Id attentionUserId,
+        String reason,
+        Change.Id changeId) {
+      this.requestContext = requestContext;
+      this.user = user;
+      this.sender = sender;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.attentionUserId = attentionUserId;
+      this.reason = reason;
+      this.changeId = changeId;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        Optional<Account.Id> accountId =
+            user.isIdentifiedUser()
+                ? Optional.of(user.asIdentifiedUser().getAccountId())
+                : Optional.empty();
+        if (accountId.isPresent()) {
+          sender.setFrom(accountId.get());
+        }
+        sender.setNotify(notify);
+        sender.setAttentionSetUser(attentionUserId);
+        sender.setReason(reason);
+        sender.setMessageId(messageId);
+        sender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return ctx.getUser();
+    @Override
+    public String toString() {
+      return "send-email attention-set-update";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index 4f4ba83..bbc6bf0 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -7,5 +7,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//lib:jgit",
+        "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/server/util/git/CloseablePool.java b/java/com/google/gerrit/server/util/git/CloseablePool.java
new file mode 100644
index 0000000..442bd09
--- /dev/null
+++ b/java/com/google/gerrit/server/util/git/CloseablePool.java
@@ -0,0 +1,116 @@
+// 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.util.git;
+
+import com.google.common.flogger.FluentLogger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * Pool to manage resources that need to be closed but to whom we might lose the reference to or
+ * where closing resources individually is not always possible.
+ *
+ * <p>This pool can be used when we want to reuse closable resources in a multithreaded context.
+ * Example:
+ *
+ * <pre>{@code
+ * try (CloseablePool<T> pool = new CloseablePool(() -> new T())) {
+ *   for (int i = 0; i < 100; i++) {
+ *     executor.submit(() -> {
+ *       try (CloseablePool<T>.Handle handle = pool.get()) {
+ *         // Do work that might potentially take longer than the timeout.
+ *         handle.get(); // pooled instance to be used
+ *       }
+ *     }).get(1000, MILLISECONDS);
+ *   }
+ * }
+ * }</pre>
+ */
+public class CloseablePool<T extends AutoCloseable> implements AutoCloseable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Supplier<T> tCreator;
+  private List<T> ts;
+
+  /**
+   * Instantiate a new pool. The {@link Supplier} must be capable of creating a new instance on
+   * every call.
+   */
+  public CloseablePool(Supplier<T> tCreator) {
+    this.ts = new ArrayList<>();
+    this.tCreator = tCreator;
+  }
+
+  /**
+   * Get a shared instance or create a new instance. Close the returned handle to return it to the
+   * pool.
+   */
+  public synchronized Handle get() {
+    if (ts.isEmpty()) {
+      return new Handle(tCreator.get());
+    }
+    return new Handle(ts.remove(ts.size() - 1));
+  }
+
+  private synchronized boolean discard(T t) {
+    if (ts != null) {
+      ts.add(t);
+      return true;
+    }
+    return false;
+  }
+
+  @Override
+  public synchronized void close() {
+    for (T t : ts)
+      try {
+        t.close();
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log(
+            "Failed to close resource %s in CloseablePool %s", t, this);
+      }
+    ts = null;
+  }
+
+  /**
+   * Wrapper around an {@link AutoCloseable}. Will try to return the resource to the pool and close
+   * it in case the pool was already closed.
+   */
+  public class Handle implements AutoCloseable {
+    private final T t;
+
+    private Handle(T t) {
+      this.t = t;
+    }
+
+    /** Returns the managed instance. */
+    public T get() {
+      return t;
+    }
+
+    @Override
+    public void close() {
+      if (!discard(t)) {
+        try {
+          t.close();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log(
+              "Failed to close resource %s in CloseablePool %s", this, CloseablePool.this);
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/util/time/BUILD b/java/com/google/gerrit/server/util/time/BUILD
index b1126f0..f3e0091 100644
--- a/java/com/google/gerrit/server/util/time/BUILD
+++ b/java/com/google/gerrit/server/util/time/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/server/util/git",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index c847c91..c1c58c8 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -398,7 +398,7 @@
       isZeroLength = Arrays.stream(stackTrace).anyMatch(s -> s.toString().contains(zeroLength));
     }
     if (!isZeroLength) {
-      logger.atSevere().withCause(e).log(message.toString());
+      logger.atSevere().withCause(e).log("%s", message);
     }
   }
 
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index 553287ec..cc35a32 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -85,6 +85,7 @@
 import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory;
 import org.apache.sshd.common.kex.BuiltinDHFactories;
 import org.apache.sshd.common.kex.KeyExchangeFactory;
+import org.apache.sshd.common.kex.extension.DefaultServerKexExtensionHandler;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.mac.Mac;
 import org.apache.sshd.common.random.Random;
@@ -480,6 +481,7 @@
   }
 
   private void initKeyExchanges(Config cfg) {
+    setKexExtensionHandler(DefaultServerKexExtensionHandler.INSTANCE);
     List<KeyExchangeFactory> a =
         NamedFactory.setUpTransformedFactories(
             true,
@@ -641,7 +643,7 @@
           msg.append(avail[i].getName());
         }
         msg.append(" is supported");
-        logger.atSevere().log(msg.toString());
+        logger.atSevere().log("%s", msg);
       } else if (add) {
         if (!def.contains(n)) {
           def.add(n);
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 92666f3..3f2e2ad 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -110,7 +110,7 @@
         msg.append(currentUser.getAccountId());
         msg.append("): ");
         msg.append(badStream.getCause().getMessage());
-        logger.atInfo().log(msg.toString());
+        logger.atInfo().log("%s", msg);
         throw new UnloggedFailure(128, "error: " + badStream.getCause().getMessage());
       }
       StringBuilder msg = new StringBuilder();
diff --git a/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 6912795..3be98fd 100644
--- a/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -147,7 +147,7 @@
     for (; ; ) {
       int c = in.read();
       if (c == '\n') {
-        return baos.toString();
+        return baos.toString(UTF_8);
       } else if (c == -1) {
         throw new IOException("End of stream");
       } else {
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 798a2d4..fea00e9 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -48,6 +48,7 @@
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
+        "//lib/log:impl-log4j",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index c374691..7c42797 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -456,7 +456,7 @@
 
     MyParser(Object bean) {
       super(bean, ParserProperties.defaults().withAtSyntax(false));
-      parseAdditionalOptions(bean, new HashSet<>());
+      parseAdditionalOptions("", bean, new HashSet<>());
       addOptionsWithMetRequirements();
       ensureOptionsInitialized();
     }
@@ -527,7 +527,7 @@
       }
     }
 
-    private void parseAdditionalOptions(Object bean, Set<Object> parsedBeans) {
+    private void parseAdditionalOptions(String prefix, Object bean, Set<Object> parsedBeans) {
       for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
         for (Field f : c.getDeclaredFields()) {
           if (f.isAnnotationPresent(Options.class)) {
@@ -537,7 +537,8 @@
             } catch (IllegalAccessException e) {
               throw new IllegalAnnotationError(e);
             }
-            parseWithPrefix(f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
+            parseWithPrefix(
+                prefix + f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
           }
         }
       }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 1a79b53..c5f0d23 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -85,6 +85,7 @@
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -232,6 +233,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Inject
   @Named("diff_intraline")
@@ -2948,7 +2950,7 @@
   }
 
   @Test
-  public void queryChangesNoLimit() throws Exception {
+  public void queryChangesNoLimitRegisteredUser() throws Exception {
     projectOperations
         .allProjectsForUpdate()
         .add(
@@ -2966,6 +2968,26 @@
   }
 
   @Test
+  public void queryChangesNoLimitIgnoredForAnonymousUser() throws Exception {
+    int limit = 2;
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.QUERY_LIMIT)
+                .group(SystemGroupBackend.ANONYMOUS_USERS)
+                .range(0, limit))
+        .update();
+    for (int i = 0; i < 3; i++) {
+      createChange();
+    }
+    requestScopeOperations.setApiUserAnonymous();
+    List<ChangeInfo> resultsWithDefaultLimit = gApi.changes().query().get();
+    List<ChangeInfo> resultsWithNoLimit = gApi.changes().query().withNoLimit().get();
+    assertThat(resultsWithDefaultLimit).hasSize(limit);
+    assertThat(resultsWithNoLimit).hasSize(limit);
+  }
+
+  @Test
   public void queryChangesStart() throws Exception {
     PushOneCommit.Result r1 = createChange();
     createChange();
@@ -3135,11 +3157,8 @@
   public void submitStaleChange() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    disableChangeIndexWrites();
-    try {
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
       r = amendChange(r.getChangeId());
-    } finally {
-      enableChangeIndexWrites();
     }
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
@@ -4680,7 +4699,7 @@
     PushOneCommit.Result change = createChange();
     int number = gApi.changes().id(change.getChangeId()).get()._number;
 
-    try (AutoCloseable ignored = disableChangeIndex()) {
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
       assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
           .isEqualTo(change.getChangeId());
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
index aee7f6f..2bde1652 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -1,3 +1,17 @@
+// 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.
+
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index f8991b4..9e7a693 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
@@ -66,6 +67,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.OnPostReview;
@@ -78,11 +80,14 @@
 import com.google.inject.Module;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -633,8 +638,15 @@
     assertThat(r.getChange().approvals().values()).hasSize(1);
 
     // Post without changing the vote.
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+    ObjectId metaId = notes.getMetaId();
+    assertAttentionSet(notes.getAttentionSet(), user.id());
     input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
     gApi.changes().id(r.getChangeId()).current().review(input);
+    notes = notesFactory.create(project, r.getChange().getId());
+    // Change meta ID did not change since the update is No/Op. Attention set is same.
+    assertThat(notes.getMetaId()).isEqualTo(metaId);
+    assertAttentionSet(notes.getAttentionSet(), user.id());
 
     // Second vote replaced the original vote, so still only one vote.
     assertThat(r.getChange().approvals().values()).hasSize(1);
@@ -1050,9 +1062,17 @@
 
     @Override
     public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      count++;
+      if (!isAsyncCallForSendingReviewCommentsEmail()) {
+        count++;
+      }
       return Optional.empty();
     }
+
+    private boolean isAsyncCallForSendingReviewCommentsEmail() {
+      return Arrays.stream(Thread.currentThread().getStackTrace())
+          .map(StackTraceElement::getClassName)
+          .anyMatch(className -> EmailReviewComments.class.getName().equals(className));
+    }
   }
 
   private static class TestReviewerAddedListener implements ReviewerAddedListener {
@@ -1086,4 +1106,10 @@
           .collect(toImmutableSet());
     }
   }
+
+  private static void assertAttentionSet(
+      ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) {
+    assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList()))
+        .containsExactlyElementsIn(accounts);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index e00a137..90ca047 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -20,7 +20,11 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -46,6 +50,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ChangeUtil;
+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.permissions.PermissionDeniedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -64,6 +72,7 @@
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
@@ -513,6 +522,24 @@
   }
 
   @Test
+  public void revertWithValidationOptions() throws Exception {
+    PushOneCommit.Result result = createChange();
+    approve(result.getChangeId());
+    gApi.changes().id(result.getChangeId()).current().submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(result.getChangeId()).revert(revertInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   @GerritConfig(name = "change.submitWholeTopic", value = "true")
   public void cantCreateRevertSubmissionWithoutProjectWritePermission() throws Exception {
     String secondProject = "secondProject";
@@ -1461,4 +1488,15 @@
     input.workInProgress = true;
     return input;
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index ff8a2d0..a08f00a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -29,6 +29,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Comparator.comparing;
 
 import com.google.common.cache.Cache;
@@ -38,12 +39,14 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
@@ -62,6 +65,7 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -75,7 +79,10 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Consumer;
 import java.util.stream.Collectors;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
@@ -83,6 +90,7 @@
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
   @Inject private ChangeKindCreator changeKindCreator;
@@ -103,8 +111,8 @@
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
               value(0, "No score"),
-              value(-1, "I would prefer that you didn't submit this"),
-              value(-2, "Do not submit"));
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
       codeReview.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(codeReview.build());
 
@@ -139,12 +147,28 @@
   }
 
   @Test
-  public void stickyOnAnyScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+  public void stickyOnAnyScore_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyOnAnyScore();
+  }
 
+  @Test
+  public void stickyOnAnyScore_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    testStickyOnAnyScore();
+  }
+
+  @Test
+  public void stickyOnRework() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:REWORK"));
+
+    // changekind:REWORK should match all kind of changes so that approvals are always copied.
+    // This means setting changekind:REWORK is equivalent to setting is:ANY and we can do the same
+    // assertions for both cases.
+    testStickyOnAnyScore();
+  }
+
+  private void testStickyOnAnyScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -161,55 +185,18 @@
   }
 
   @Test
-  public void stickyWhenCopyConditionIsTrue() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:ANY"));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, 1, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, 1, 0, changeKind);
-    }
+  public void stickyOnMinScore_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMinScore(true));
+    testStickyOnMinScore();
   }
 
   @Test
-  public void stickyEvenWhenUserCantSeeUploaderInGroup() throws Exception {
-    // user can't see admin group
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyCondition("approverin:" + administratorsUUID));
-      u.save();
-    }
-
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-    amendChange(changeId);
-    vote(user, changeId, 1, -1); // Invalidate cache
-    requestScopeOperations.setApiUser(user.id());
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, 1, -1);
+  public void stickyOnMinScore_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:min"));
+    testStickyOnMinScore();
   }
 
-  @Test
-  public void stickyOnMinScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
-      u.save();
-    }
-
+  private void testStickyOnMinScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -226,13 +213,1346 @@
   }
 
   @Test
-  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
-      u.save();
+  public void stickyOnMaxScore_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    testStickyOnMaxScore();
+  }
+
+  @Test
+  public void stickyOnMaxScore_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:max"));
+    testStickyOnMaxScore();
+  }
+
+  private void testStickyOnMaxScore() throws Exception {
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
     }
+  }
+
+  @Test
+  public void stickyOnCopyValues_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
+    testStickyOnCopyValues();
+  }
+
+  @Test
+  public void stickyOnCopyValues_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:\"-1\" OR is:1"));
+    testStickyOnCopyValues();
+  }
+
+  private void testStickyOnCopyValues() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, -1, 1);
+      vote(user, changeId, -2, -1);
+      vote(user2, changeId, 1, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, -1, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+      assertVotes(c, user2, 1, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyOnTrivialRebase_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnTrivialRebase(true));
+    testStickyOnTrivialRebase();
+  }
+
+  @Test
+  public void stickyOnTrivialRebase_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
+    testStickyOnTrivialRebase();
+  }
+
+  private void testStickyOnTrivialRebase() throws Exception {
+    String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    changeKindCreator.updateChange(changeId, TRIVIAL_REBASE, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
+    assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
+
+    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
+
+    // check that votes are sticky when trivial rebase is done by cherry-pick
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    changeId = createChange().getChangeId();
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    String cherryPickChangeId =
+        changeKindCreator.cherryPick(changeId, TRIVIAL_REBASE, testRepo, admin, project);
+    c = detailedChange(cherryPickChangeId);
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+
+    // check that votes are not sticky when rework is done by cherry-pick
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    changeId = createChange().getChangeId();
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    cherryPickChangeId = changeKindCreator.cherryPick(changeId, REWORK, testRepo, admin, project);
+    c = detailedChange(cherryPickChangeId);
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void stickyOnNoCodeChange_withoutCopyCondition() throws Exception {
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testStickyOnNoCodeChange();
+  }
+
+  @Test
+  public void stickyOnNoCodeChange_withCopyCondition() throws Exception {
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testStickyOnNoCodeChange();
+  }
+
+  private void testStickyOnNoCodeChange() throws Exception {
+    String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 1, NO_CHANGE);
+    assertVotes(c, user, 0, -1, NO_CHANGE);
+
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
+    assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
+
+    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
+  }
+
+  @Test
+  public void stickyOnMergeFirstParentUpdate_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
+    testStickyOnMergeFirstParentUpdate();
+  }
+
+  @Test
+  public void stickyOnMergeFirstParentUpdate_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
+    testStickyOnMergeFirstParentUpdate();
+  }
+
+  private void testStickyOnMergeFirstParentUpdate() throws Exception {
+    String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    changeKindCreator.updateChange(changeId, MERGE_FIRST_PARENT_UPDATE, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
+    assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
+
+    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, TRIVIAL_REBASE));
+  }
+
+  @Test
+  public void
+      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
+    testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate();
+  }
+
+  @Test
+  public void
+      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
+    testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate();
+  }
+
+  private void testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate()
+      throws Exception {
+    // Create a change with a non-merge commit
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    // Make a NO_CHANGE update (expect that votes are not copied since it's not a merge change).
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, NO_CHANGE);
+    assertVotes(c, user, -0, 0, NO_CHANGE);
+  }
+
+  @Test
+  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withoutCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfNoChange(true));
+    testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated();
+  }
+
+  @Test
+  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + NO_CHANGE.name()));
+    testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated();
+  }
+
+  private void testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
+    String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    changeKindCreator.updateSecondParent(changeId, testRepo, admin);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, null);
+    assertVotes(c, user, 0, 0, null);
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
+  }
+
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
+  }
+
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+      throws Exception {
+    // create "existing file" and submit it.
+    String existingFile = "existing file";
+    Change.Id prep =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(existingFile)
+            .content("content")
+            .create();
+    vote(admin, prep.toString(), 2, 1);
+    gApi.changes().id(prep.get()).current().submit();
+
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file(existingFile)
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed ("existing file" was added to the
+    // change).
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
+  }
+
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+  }
+
+  @Test
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+  }
+
+  private void testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
+  }
+
+  @Test
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
+  }
+
+  private void testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
+      throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Modify f.txt in change 1. Approve and submit the first change
+    gApi.changes().id(r.getChangeId()).edit().modifyFile("f.txt", RawInputUtil.create("content"));
+    gApi.changes().id(r.getChangeId()).edit().publish();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
+    revision.submit();
+
+    // Add an approval whose score should be copied on change 2.
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Rebase the second change. The rebase adds f1.txt.
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    // The code-review approval is copied for the second change between PS1 and PS2 since the only
+    // modified file is due to rebase.
+    List<PatchSetApproval> patchSetApprovals =
+        r2.getChange().notes().getApprovals().all().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertCopied(nonCopied, /* psId= */ 1, LabelId.CODE_REVIEW, (short) 1, false);
+    assertCopied(copied, /* psId= */ 2, LabelId.CODE_REVIEW, (short) 1, true);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
+  }
+
+  private void
+      testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+          throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
+  }
+
+  private void
+      testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
+          throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("new file").content("content").create();
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // Don't copy over votes since ps1->ps2 should copy over, but ps2->ps3 should not.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
+  }
+
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed (rename).
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void copyWithListOfFilesUnchanged_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testCopyWithListOfFilesUnchanged();
+  }
+
+  @Test
+  public void copyWithListOfFilesUnchanged_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testCopyWithListOfFilesUnchanged();
+  }
+
+  private void testCopyWithListOfFilesUnchanged() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // Code-Review votes are copied over from ps1-> ps2 since the list of files were unchanged.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("very new content")
+        .create();
+    c = detailedChange(changeId.toString());
+
+    // Code-Review votes are copied over from ps1-> ps3 since the list of files were unchanged.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+
+    c = detailedChange(changeId.toString());
+    // Code-Review votes are not copied over from ps1-> ps4 since a file was added.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("content").create();
+
+    c = detailedChange(changeId.toString());
+    // Code-Review votes are not copied over from ps1 -> ps5 since a file was added on ps4.
+    // Although the list of files is the same between ps4->ps5, we don't copy votes from before
+    // ps4.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  // This test doesn't work without copy condition (when
+  // setCopyAllScoresIfListOfFilesDidNotChange=true).
+  // This is because magic files are not ignored for setCopyAllScoresIfListOfFilesDidNotChange.
+  @Test
+  public void stickyIfFilesUnchanged_magicFilesAreIgnored() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id dummyParentChangeId = changeOperations.newChange().project(project).create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    // The change is for a merge commit. It doesn't touch any files, but contains /COMMIT_MSG and
+    // /MERGE_LIST as magic files.
+    Map<String, FileInfo> changedFilesFirstPatchset =
+        gApi.changes().id(changeId.get()).current().files();
+    assertThat(changedFilesFirstPatchset.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST");
+
+    // Add a Code-Review+2 vote.
+    approve(changeId.toString());
+
+    // Create a new patch set with a non-merge commit.
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .parent()
+        .patchset(PatchSet.id(dummyParentChangeId, 1))
+        .create();
+
+    // Since the new patch set is not a merge commit, it no longer contains the magic /MERGE_LIST
+    // file.
+    Map<String, FileInfo> changedFilesSecondPatchset =
+        gApi.changes().id(changeId.get()).current().files();
+    assertThat(changedFilesSecondPatchset.keySet())
+        .containsExactly("/COMMIT_MSG"); // /MERGE_LIST is no longer present
+
+    // The only file difference between the 2 patch sets is the magic /MERGE_LIST file.
+    // Since magic files are ignored when checking whether the files between the patch sets differ,
+    // has:unchanged-file should evaluate to true and we expect that the vote was copied.
+    ChangeInfo c = detailedChange(changeId.toString());
+    assertVotes(c, admin, 2, 0);
+  }
+
+  @Test
+  public void approvalsAreStickyIfUploaderMatchesUploaderinCondition() throws Exception {
+    TestAccount userForWhomApprovalsAreSticky =
+        accountCreator.create(
+            /* username= */ null,
+            /* email= */ "userForWhomApprovalsAreSticky@example.com",
+            /* fullName= */ "User For Whom Approvals Are Sticky",
+            /* displayName= */ null);
+    String usersForWhomApprovalsAreStickyUuid =
+        groupOperations
+            .newGroup()
+            .name("Users-for-whom-approvals-are-sticky")
+            .addMember(userForWhomApprovalsAreSticky.id())
+            .create()
+            .get();
+
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("uploaderin:" + usersForWhomApprovalsAreStickyUuid));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user.
+    approve(r.getChangeId());
+
+    // Add Code-Review+1 by user.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    // Create a new patch set by userForWhomApprovalsAreSticky (approavls are sticky).
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(project, userForWhomApprovalsAreSticky);
+    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
+    userTestRepo.reset("ps");
+    amendChange(r.getChangeId(), "refs/for/master", userForWhomApprovalsAreSticky, userTestRepo)
+        .assertOkStatus();
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertThat(c.revisions.get(c.currentRevision).uploader._accountId)
+        .isEqualTo(userForWhomApprovalsAreSticky.id().get());
+
+    // Approvals are sticky.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, 1, 0);
+
+    // Create a new patch set by admin user (approvals are not sticky).
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    // Approvals are not sticky.
+    c = detailedChange(r.getChangeId());
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void approvalsThatMatchApproverinConditionAreSticky() throws Exception {
+    TestAccount userWhoseApprovalsAreSticky = accountCreator.create();
+    String usersWhoseApprovalsAreStickyUuid =
+        groupOperations
+            .newGroup()
+            .name("Users-whose-approvals-are-sticky")
+            .addMember(userWhoseApprovalsAreSticky.id())
+            .create()
+            .get();
+
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("approverin:" + usersWhoseApprovalsAreStickyUuid));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user (not sticky).
+    approve(r.getChangeId());
+
+    // Add Code-Review+1 by userWhoseApprovalsAreSticky (sticky).
+    requestScopeOperations.setApiUser(userWhoseApprovalsAreSticky.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, userWhoseApprovalsAreSticky, 1, 0);
+  }
+
+  @Test
+  public void approvalsThatMatchApproverinConditionAreStickyEvenIfUserCantSeeTheGroup()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    // Verify that user can't see the admin group.
+    requestScopeOperations.setApiUser(user.id());
+    ResourceNotFoundException notFound =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.groups().id(administratorsUUID).get());
+    assertThat(notFound).hasMessageThat().isEqualTo("Not found: " + administratorsUUID);
+
+    requestScopeOperations.setApiUser(admin.id());
+    updateCodeReviewLabel(b -> b.setCopyCondition("approverin:" + administratorsUUID));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user.
+    approve(r.getChangeId());
+
+    // Create a new patch set by user.
+    // Approvals are copied on creation of the new patch set. The approval of the admin user is
+    // expected to be sticky although the group that is configured for the 'approverin' predicate is
+    // not visible to the user.
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
+    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
+    userTestRepo.reset("ps");
+    amendChange(r.getChangeId(), "refs/for/master", user, userTestRepo).assertOkStatus();
+
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertThat(c.revisions.get(c.currentRevision).uploader._accountId).isEqualTo(user.id().get());
+
+    // Assert that the approval of the admin user was copied to the new patch set.
+    assertVotes(c, admin, 2, 0);
+  }
+
+  @Test
+  public void removedVotesNotSticky_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnTrivialRebase(true));
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testRemovedVotesNotSticky();
+  }
+
+  @Test
+  public void removedVotesNotSticky_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testRemovedVotesNotSticky();
+  }
+
+  private void testRemovedVotesNotSticky() throws Exception {
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      // Remove votes by re-voting with 0
+      vote(admin, changeId, 0, 0);
+      vote(user, changeId, 0, 0);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, null);
+      assertVotes(c, user, 0, 0, null);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSets_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testStickyAcrossMultiplePatchSets();
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSets_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testStickyAcrossMultiplePatchSets();
+  }
+
+  private void testStickyAcrossMultiplePatchSets() throws Exception {
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+
+    for (int i = 0; i < 5; i++) {
+      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
+    }
+
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance_withoutCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testStickyAcrossMultiplePatchSetsDoNotRegressPerformance();
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance_withCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testStickyAcrossMultiplePatchSetsDoNotRegressPerformance();
+  }
+
+  private void testStickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
+    // The purpose of this test is to make sure that we compute change kind only against the parent
+    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
+    // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
+    // work in O(num-patch-sets). This test ensures that we aren't regressing.
+
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+
+    Map<Integer, ObjectId> revisions = new HashMap<>();
+    gApi.changes()
+        .id(changeId)
+        .get()
+        .revisions
+        .forEach(
+            (revId, revisionInfo) ->
+                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
+    assertThat(revisions.size()).isEqualTo(4);
+    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
+    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
+    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));
+
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
+  }
+
+  @Test
+  public void copyMinMaxAcrossMultiplePatchSets_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true).setCopyMinScore(true));
+    testCopyMinMaxAcrossMultiplePatchSets();
+  }
+
+  @Test
+  public void copyMinMaxAcrossMultiplePatchSets_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX OR is:MIN"));
+    testCopyMinMaxAcrossMultiplePatchSets();
+  }
+
+  private void testCopyMinMaxAcrossMultiplePatchSets() throws Exception {
+    // Vote max score on PS1
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+
+    // Have someone else vote min score on PS2
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    vote(user, changeId, -2, 0);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // No vote changes on PS3
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // Both users revote on PS4
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    vote(admin, changeId, 1, 1);
+    vote(user, changeId, 1, 1);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 1, 1, REWORK);
+    assertVotes(c, user, 1, 1, REWORK);
+
+    // New approvals shouldn't carry through to PS5
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, REWORK);
+    assertVotes(c, user, 0, 0, REWORK);
+  }
+
+  @Test
+  public void deleteStickyVote_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    testDeleteStickyVote();
+  }
+
+  @Test
+  public void deleteStickyVote_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    testDeleteStickyVote();
+  }
+
+  private void testDeleteStickyVote() throws Exception {
+    String label = LabelId.CODE_REVIEW;
+
+    // Vote max score on PS1
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, label, 2);
+    assertVotes(detailedChange(changeId), admin, label, 2, null);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
+
+    // Delete vote that was copied via sticky approval
+    deleteVote(admin, changeId, label);
+    assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
+  }
+
+  @Test
+  public void canVoteMultipleTimesOnNewPatchsets_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testCanVoteMultipleTimesOnNewPatchsets();
+  }
+
+  @Test
+  public void canVoteMultipleTimesOnNewPatchsets_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testCanVoteMultipleTimesOnNewPatchsets();
+  }
+
+  private void testCanVoteMultipleTimesOnNewPatchsets() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +2 vote.
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Post without changing the vote.
+    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // There is a vote both on patch set 1 and on patch set 2, although both votes are Code-Review
+    // +2. The approval on patch set 2 is no longer copied since it was reapplied.
+    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+  }
+
+  @Test
+  public void copiedVoteIsNotReapplied_onVoteOnOtherLabel() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add vote that will be copied.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Create a new patchset, the Code-Review +2 vote is copied.
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Vote on another label. This shouldn't touch the copied approval.
+    input = new ReviewInput().label(LabelId.VERIFIED, 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Patch set 2 has 2 approvals now, one copied approval for the Code-Review label and one
+    // non-copied
+    // approval for the Verified label.
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(2);
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.CODE_REVIEW.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isTrue();
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.VERIFIED.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isFalse();
+  }
+
+  @Test
+  public void copiedVoteIsNotReapplied_onRebase() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Create a sibling change
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Add vote that will be copied.
+    approve(r2.getChangeId());
+
+    // Verify that that the approval exists and is not copied.
+    List<PatchSetApproval> approvalsPs2 = r2.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+
+    // Approve, verify and submit the first change.
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Rebase the second change, the approval should be sticky.
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    approvalsPs2 = changeDataFactory.create(project, r2.getChange().getId()).currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUpload_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnUpload();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUpload_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnUpload();
+  }
+
+  private void testStickyVoteStoredOnUpload() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    input.tag = "tag";
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make new patchsets, keeping the Code-Review +2 vote.
+    for (int i = 0; i < 9; i++) {
+      amendChange(r.getChangeId());
+    }
+
+    List<PatchSetApproval> patchSetApprovals =
+        r.getChange().notes().getApprovals().all().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+
+    for (int i = 0; i < 10; i++) {
+      int patchSet = i + 1;
+      assertThat(patchSetApprovals.get(i).patchSetId().get()).isEqualTo(patchSet);
+      assertThat(patchSetApprovals.get(i).accountId().get()).isEqualTo(admin.id().get());
+      assertThat(patchSetApprovals.get(i).realAccountId().get()).isEqualTo(admin.id().get());
+      assertThat(patchSetApprovals.get(i).label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(patchSetApprovals.get(i).value()).isEqualTo((short) 2);
+      assertThat(patchSetApprovals.get(i).tag().get()).isEqualTo("tag");
+      if (patchSet == 1) {
+        assertThat(patchSetApprovals.get(i).copied()).isFalse();
+      } else {
+        assertThat(patchSetApprovals.get(i).copied()).isTrue();
+      }
+    }
+  }
+
+  @Test
+  public void stickyVoteStoredOnRebase_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnRebase();
+  }
+
+  @Test
+  public void stickyVoteStoredOnRebase_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnRebase();
+  }
+
+  private void testStickyVoteStoredOnRebase() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
+    revision.submit();
+
+    // Add an approval whose score should be copied.
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Rebase the second change
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    List<PatchSetApproval> patchSetApprovals =
+        r2.getChange().notes().getApprovals().all().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertCopied(nonCopied, 1, LabelId.CODE_REVIEW, (short) 1, /* copied= */ false);
+    assertCopied(copied, 2, LabelId.CODE_REVIEW, (short) 1, /* copied= */ true);
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccount_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnUploadWithRealAccount();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccount_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnUploadWithRealAccount();
+  }
+
+  private void testStickyVoteStoredOnUploadWithRealAccount() throws Exception {
+    // Give "user" permission to vote on behalf of other users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .impersonation(true)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote as user
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+    input.onBehalfOf = admin.email();
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +1 vote.
+    amendChange(r.getChangeId());
+
+    List<PatchSetApproval> patchSetApprovals =
+        r.getChange().notes().getApprovals().all().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
+    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(nonCopied.value()).isEqualTo((short) 1);
+    assertThat(nonCopied.copied()).isFalse();
+
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertThat(copied.patchSetId().get()).isEqualTo(2);
+    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(copied.value()).isEqualTo((short) 1);
+    assertThat(copied.copied()).isTrue();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withoutCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnUploadWithRealAccountAndTag();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnUploadWithRealAccountAndTag();
+  }
+
+  private void testStickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
+    // Give "user" permission to vote on behalf of other users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .impersonation(true)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote as user
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+    input.onBehalfOf = admin.email();
+    input.tag = "tag";
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +1 vote.
+    amendChange(r.getChangeId());
+
+    List<PatchSetApproval> patchSetApprovals =
+        r.getChange().notes().getApprovals().all().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
+    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(nonCopied.value()).isEqualTo((short) 1);
+    assertThat(nonCopied.tag().get()).isEqualTo("tag");
+    assertThat(nonCopied.copied()).isFalse();
+
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertThat(copied.patchSetId().get()).isEqualTo(2);
+    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(copied.value()).isEqualTo((short) 1);
+    assertThat(nonCopied.tag().get()).isEqualTo("tag");
+    assertThat(copied.copied()).isTrue();
+  }
+
+  @Test
+  public void stickyVoteStoredCanBeRemoved_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredCanBeRemoved();
+  }
+
+  @Test
+  public void stickyVoteStoredCanBeRemoved_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredCanBeRemoved();
+  }
+
+  private void testStickyVoteStoredCanBeRemoved() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +2 vote.
+    amendChange(r.getChangeId());
+    assertVotes(detailedChange(r.getChangeId()), admin, LabelId.CODE_REVIEW, 2, null);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    PatchSetApproval nonCopiedSecondPatchsetRemovedVote =
+        Iterables.getOnlyElement(
+            r.getChange()
+                .notes()
+                .getApprovals()
+                .all()
+                .get(r.getChange().change().currentPatchSetId()));
+
+    assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
+    assertThat(nonCopiedSecondPatchsetRemovedVote.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(nonCopiedSecondPatchsetRemovedVote.label()).isEqualTo(LabelId.CODE_REVIEW);
+    // The vote got removed since the latest patch-set only has one vote and it's "0".
+    assertThat(nonCopiedSecondPatchsetRemovedVote.value()).isEqualTo((short) 0);
+    assertThat(nonCopiedSecondPatchsetRemovedVote.copied()).isFalse();
+  }
+
+  @Test
+  public void reviewerStickyVotingCanBeRemoved_withoutCopyConfition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testReviewerStickyVotingCanBeRemoved();
+  }
+
+  @Test
+  public void reviewerStickyVotingCanBeRemoved_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testReviewerStickyVotingCanBeRemoved();
+  }
+
+  private void testReviewerStickyVotingCanBeRemoved() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote by user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Make a new patchset, keeping the Code-Review +1 vote.
+    amendChange(r.getChangeId());
+    assertVotes(detailedChange(r.getChangeId()), user, LabelId.CODE_REVIEW, 1, null);
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    assertThat(r.getChange().notes().getApprovals().all()).isEmpty();
+
+    // Changes message has info about vote removed.
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
+        .contains("Code-Review+1 by User");
+  }
+
+  @Test
+  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -250,33 +1570,8 @@
   }
 
   @Test
-  public void stickyOnMaxScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, 1, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-    }
-  }
-
-  @Test
   public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setFunction(LabelFunction.NO_BLOCK));
-      u.save();
-    }
+    updateVerifiedLabel(b -> b.setFunction(LabelFunction.NO_BLOCK));
 
     // This test is covering the backfilling logic for changes which have been submitted, based on
     // copied approvals, before Gerrit persisted copied votes as Copied-Label footers in NoteDb. It
@@ -331,8 +1626,7 @@
       vote(admin, changeId, 2, 1);
 
       List<PatchSetApproval> patchSetApprovals =
-          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
-              .stream()
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
               .sorted(comparing(a -> a.patchSetId().get()))
               .collect(toImmutableList());
 
@@ -354,8 +1648,7 @@
       gApi.changes().id(changeId).current().submit();
 
       patchSetApprovals =
-          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
-              .stream()
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
               .sorted(comparing(a -> a.patchSetId().get()))
               .collect(toImmutableList());
 
@@ -378,1041 +1671,6 @@
     }
   }
 
-  @Test
-  public void stickyOnCopyValues() throws Exception {
-    TestAccount user2 = accountCreator.user2();
-
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, -1, 1);
-      vote(user, changeId, -2, -1);
-      vote(user2, changeId, 1, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, -1, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-      assertVotes(c, user2, 1, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyOnTrivialRebase() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, NO_CHANGE);
-    assertVotes(c, user, -2, 0, NO_CHANGE);
-
-    changeKindCreator.updateChange(changeId, TRIVIAL_REBASE, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
-    assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
-
-    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
-
-    // check that votes are sticky when trivial rebase is done by cherry-pick
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    changeId = createChange().getChangeId();
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    String cherryPickChangeId =
-        changeKindCreator.cherryPick(changeId, TRIVIAL_REBASE, testRepo, admin, project);
-    c = detailedChange(cherryPickChangeId);
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
-
-    // check that votes are not sticky when rework is done by cherry-pick
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    changeId = createChange().getChangeId();
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    cherryPickChangeId = changeKindCreator.cherryPick(changeId, REWORK, testRepo, admin, project);
-    c = detailedChange(cherryPickChangeId);
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void stickyOnNoCodeChange() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 1, NO_CHANGE);
-    assertVotes(c, user, 0, -1, NO_CHANGE);
-
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
-    assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
-
-    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
-  }
-
-  @Test
-  public void stickyOnMergeFirstParentUpdate() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, NO_CHANGE);
-    assertVotes(c, user, -2, 0, NO_CHANGE);
-
-    changeKindCreator.updateChange(changeId, MERGE_FIRST_PARENT_UPDATE, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
-    assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
-
-    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, TRIVIAL_REBASE));
-  }
-
-  @Test
-  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfNoChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    changeKindCreator.updateSecondParent(changeId, testRepo, admin);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 0, null);
-    assertVotes(c, user, 0, 0, null);
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
-
-  @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withCopyCondition()
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
-
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
-      throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .file("new file")
-        .content("new content")
-        .create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // no votes are copied since the list of files changed.
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
-      throws Exception {
-    // create "existing file" and submit it.
-    String existingFile = "existing file";
-    Change.Id prep =
-        changeOperations
-            .newChange()
-            .project(project)
-            .file(existingFile)
-            .content("content")
-            .create();
-    vote(admin, prep.toString(), 2, 1);
-    gApi.changes().id(prep.get()).current().submit();
-
-    Change.Id changeId = changeOperations.newChange().project(project).create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .file(existingFile)
-        .content("new content")
-        .create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // no votes are copied since the list of files changed ("existing file" was added to the
-    // change).
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
-      throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations.change(changeId).newPatchset().file("file").delete().create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // no votes are copied since the list of files changed.
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
-  }
-
-  @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Modify f.txt in change 1. Approve and submit the first change
-    gApi.changes().id(r.getChangeId()).edit().modifyFile("f.txt", RawInputUtil.create("content"));
-    gApi.changes().id(r.getChangeId()).edit().publish();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
-    revision.submit();
-
-    // Add an approval whose score should be copied on change 2.
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
-    // Rebase the second change. The rebase adds f1.txt.
-    gApi.changes().id(r2.getChangeId()).rebase();
-
-    // The code-review approval is copied for the second change between PS1 and PS2 since the only
-    // modified file is due to rebase.
-    List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
-            .sorted(comparing(a -> a.patchSetId().get()))
-            .collect(toImmutableList());
-    PatchSetApproval nonCopied = patchSetApprovals.get(0);
-    PatchSetApproval copied = patchSetApprovals.get(1);
-    assertCopied(nonCopied, /* psId= */ 1, LabelId.CODE_REVIEW, (short) 1, false);
-    assertCopied(copied, /* psId= */ 2, LabelId.CODE_REVIEW, (short) 1, true);
-  }
-
-  @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
-  }
-
-  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
-      throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
-    // configured for that label, and list of files didn't change.
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
-  }
-
-  @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
-
-  @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
-
-  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
-      throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
-    // configured for that label, and list of files didn't change.
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
-
-  private void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
-          throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations.change(changeId).newPatchset().file("new file").content("content").create();
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .file("new file")
-        .content("new content")
-        .create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // Don't copy over votes since ps1->ps2 should copy over, but ps2->ps3 should not.
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
-      throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // no votes are copied since the list of files changed (rename).
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void removedVotesNotSticky() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, -2, -1);
-
-      // Remove votes by re-voting with 0
-      vote(admin, changeId, 0, 0);
-      vote(user, changeId, 0, 0);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, null);
-      assertVotes(c, user, 0, 0, null);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSets() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-
-    for (int i = 0; i < 5; i++) {
-      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
-    }
-
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
-    // The purpose of this test is to make sure that we compute change kind only against the parent
-    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
-    // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
-    // work in O(num-patch-sets). This test ensures that we aren't regressing.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-
-    Map<Integer, ObjectId> revisions = new HashMap<>();
-    gApi.changes()
-        .id(changeId)
-        .get()
-        .revisions
-        .forEach(
-            (revId, revisionInfo) ->
-                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
-    assertThat(revisions.size()).isEqualTo(4);
-    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
-    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
-    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));
-
-    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
-    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
-    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
-  }
-
-  @Test
-  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
-      u.save();
-    }
-
-    // Vote max score on PS1
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-
-    // Have someone else vote min score on PS2
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    vote(user, changeId, -2, 0);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // No vote changes on PS3
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // Both users revote on PS4
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    vote(admin, changeId, 1, 1);
-    vote(user, changeId, 1, 1);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 1, 1, REWORK);
-    assertVotes(c, user, 1, 1, REWORK);
-
-    // New approvals shouldn't carry through to PS5
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 0, REWORK);
-    assertVotes(c, user, 0, 0, REWORK);
-  }
-
-  @Test
-  public void copyWithListOfFilesUnchanged_withoutCopyCondition() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    copyWithListOfFilesUnchanged();
-  }
-
-  @Test
-  public void copyWithListOfFilesUnchanged_withCopyCondition() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    copyWithListOfFilesUnchanged();
-  }
-
-  private void copyWithListOfFilesUnchanged() throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // Code-Review votes are copied over from ps1-> ps2 since the list of files were unchanged.
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
-
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .file("file")
-        .content("very new content")
-        .create();
-    c = detailedChange(changeId.toString());
-
-    // Code-Review votes are copied over from ps1-> ps3 since the list of files were unchanged.
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
-
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .file("new file")
-        .content("new content")
-        .create();
-
-    c = detailedChange(changeId.toString());
-    // Code-Review votes are not copied over from ps1-> ps4 since a file was added.
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-
-    changeOperations.change(changeId).newPatchset().file("file").content("content").create();
-
-    c = detailedChange(changeId.toString());
-    // Code-Review votes are not copied over from ps1 -> ps5 since a file was added on ps4.
-    // Although the list of files is the same between ps4->ps5, we don't copy votes from before
-    // ps4.
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void copyWithListOfFilesUnchangedButAddedMergeList() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
-    Change.Id dummyParentChangeId = changeOperations.newChange().create();
-    Change.Id changeId =
-        changeOperations
-            .newChange()
-            .mergeOf()
-            .change(parent1ChangeId)
-            .and()
-            .change(parent2ChangeId)
-            .create();
-
-    Map<String, FileInfo> changedFilesFirstPatchset =
-        gApi.changes().id(changeId.get()).current().files();
-
-    assertThat(changedFilesFirstPatchset.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST");
-
-    // Make a Code-Review vote that should be sticky.
-    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
-
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .parent()
-        .patchset(PatchSet.id(dummyParentChangeId, 1))
-        .create();
-
-    Map<String, FileInfo> changedFilesSecondPatchset =
-        gApi.changes().id(changeId.get()).current().files();
-
-    // Only "/MERGE_LIST" was removed.
-    assertThat(changedFilesSecondPatchset.keySet()).containsExactly("/COMMIT_MSG");
-    ApprovalInfo approvalInfo =
-        Iterables.getOnlyElement(
-            gApi.changes().id(changeId.get()).current().votes().get(LabelId.CODE_REVIEW));
-    assertThat(approvalInfo._accountId).isEqualTo(admin.id().get());
-    assertThat(approvalInfo.value).isEqualTo(2);
-  }
-
-  @Test
-  public void deleteStickyVote() throws Exception {
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
-      u.save();
-    }
-
-    // Vote max score on PS1
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, label, 2);
-    assertVotes(detailedChange(changeId), admin, label, 2, null);
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
-
-    // Delete vote that was copied via sticky approval
-    deleteVote(admin, changeId, label);
-    assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
-  }
-
-  @Test
-  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result r = createChange();
-
-    // Add a new vote.
-    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
-    gApi.changes().id(r.getChangeId()).current().review(input);
-
-    // Make a new patchset, keeping the Code-Review +2 vote.
-    amendChange(r.getChangeId());
-
-    // Post without changing the vote.
-    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
-    gApi.changes().id(r.getChangeId()).current().review(input);
-
-    // There is a vote both on patchset 1 and on patchset 2, although both votes are Code-Review +2.
-    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
-    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
-  }
-
-  @Test
-  public void stickyVoteStoredOnUpload() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result r = createChange();
-    // Add a new vote.
-    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
-    input.tag = "tag";
-    gApi.changes().id(r.getChangeId()).current().review(input);
-
-    // Make new patchsets, keeping the Code-Review +2 vote.
-    for (int i = 0; i < 9; i++) {
-      amendChange(r.getChangeId());
-    }
-
-    List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
-            .sorted(comparing(a -> a.patchSetId().get()))
-            .collect(toImmutableList());
-
-    for (int i = 0; i < 10; i++) {
-      int patchSet = i + 1;
-      assertThat(patchSetApprovals.get(i).patchSetId().get()).isEqualTo(patchSet);
-      assertThat(patchSetApprovals.get(i).accountId().get()).isEqualTo(admin.id().get());
-      assertThat(patchSetApprovals.get(i).realAccountId().get()).isEqualTo(admin.id().get());
-      assertThat(patchSetApprovals.get(i).label()).isEqualTo(LabelId.CODE_REVIEW);
-      assertThat(patchSetApprovals.get(i).value()).isEqualTo((short) 2);
-      assertThat(patchSetApprovals.get(i).tag().get()).isEqualTo("tag");
-      if (patchSet == 1) {
-        assertThat(patchSetApprovals.get(i).copied()).isFalse();
-      } else {
-        assertThat(patchSetApprovals.get(i).copied()).isTrue();
-      }
-    }
-  }
-
-  @Test
-  public void stickyVoteStoredOnRebase() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
-    revision.submit();
-
-    // Add an approval whose score should be copied.
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
-    // Rebase the second change
-    gApi.changes().id(r2.getChangeId()).rebase();
-
-    List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
-            .sorted(comparing(a -> a.patchSetId().get()))
-            .collect(toImmutableList());
-    PatchSetApproval nonCopied = patchSetApprovals.get(0);
-    PatchSetApproval copied = patchSetApprovals.get(1);
-    assertCopied(nonCopied, 1, LabelId.CODE_REVIEW, (short) 1, /* copied= */ false);
-    assertCopied(copied, 2, LabelId.CODE_REVIEW, (short) 1, /* copied= */ true);
-  }
-
-  @Test
-  public void stickyVoteStoredOnUploadWithRealAccount() throws Exception {
-    // Give "user" permission to vote on behalf of other users.
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel(TestLabels.codeReview().getName())
-                .impersonation(true)
-                .ref("refs/heads/*")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result r = createChange();
-
-    // Add a new vote as user
-    requestScopeOperations.setApiUser(user.id());
-    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
-    input.onBehalfOf = admin.email();
-    gApi.changes().id(r.getChangeId()).current().review(input);
-
-    // Make a new patchset, keeping the Code-Review +1 vote.
-    amendChange(r.getChangeId());
-
-    List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
-            .sorted(comparing(a -> a.patchSetId().get()))
-            .collect(toImmutableList());
-
-    PatchSetApproval nonCopied = patchSetApprovals.get(0);
-    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
-    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
-    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
-    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
-    assertThat(nonCopied.value()).isEqualTo((short) 1);
-    assertThat(nonCopied.copied()).isFalse();
-
-    PatchSetApproval copied = patchSetApprovals.get(1);
-    assertThat(copied.patchSetId().get()).isEqualTo(2);
-    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
-    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
-    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
-    assertThat(copied.value()).isEqualTo((short) 1);
-    assertThat(copied.copied()).isTrue();
-  }
-
-  @Test
-  public void stickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
-    // Give "user" permission to vote on behalf of other users.
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel(TestLabels.codeReview().getName())
-                .impersonation(true)
-                .ref("refs/heads/*")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result r = createChange();
-
-    // Add a new vote as user
-    requestScopeOperations.setApiUser(user.id());
-    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
-    input.onBehalfOf = admin.email();
-    input.tag = "tag";
-    gApi.changes().id(r.getChangeId()).current().review(input);
-
-    // Make a new patchset, keeping the Code-Review +1 vote.
-    amendChange(r.getChangeId());
-
-    List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
-            .sorted(comparing(a -> a.patchSetId().get()))
-            .collect(toImmutableList());
-
-    PatchSetApproval nonCopied = patchSetApprovals.get(0);
-    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
-    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
-    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
-    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
-    assertThat(nonCopied.value()).isEqualTo((short) 1);
-    assertThat(nonCopied.tag().get()).isEqualTo("tag");
-    assertThat(nonCopied.copied()).isFalse();
-
-    PatchSetApproval copied = patchSetApprovals.get(1);
-    assertThat(copied.patchSetId().get()).isEqualTo(2);
-    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
-    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
-    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
-    assertThat(copied.value()).isEqualTo((short) 1);
-    assertThat(nonCopied.tag().get()).isEqualTo("tag");
-    assertThat(copied.copied()).isTrue();
-  }
-
-  @Test
-  public void stickyVoteStoredCanBeRemoved() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result r = createChange();
-
-    // Add a new vote
-    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
-    gApi.changes().id(r.getChangeId()).current().review(input);
-
-    // Make a new patchset, keeping the Code-Review +2 vote.
-    amendChange(r.getChangeId());
-    assertVotes(detailedChange(r.getChangeId()), admin, label, 2, null);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
-
-    PatchSetApproval nonCopiedSecondPatchsetRemovedVote =
-        Iterables.getOnlyElement(
-            r.getChange()
-                .notes()
-                .getApprovalsWithCopied()
-                .get(r.getChange().change().currentPatchSetId()));
-
-    assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
-    assertThat(nonCopiedSecondPatchsetRemovedVote.accountId().get()).isEqualTo(admin.id().get());
-    assertThat(nonCopiedSecondPatchsetRemovedVote.label()).isEqualTo(LabelId.CODE_REVIEW);
-    // The vote got removed since the latest patch-set only has one vote and it's "0".
-    assertThat(nonCopiedSecondPatchsetRemovedVote.value()).isEqualTo((short) 0);
-    assertThat(nonCopiedSecondPatchsetRemovedVote.copied()).isFalse();
-  }
-
-  @Test
-  public void reviewerStickyVotingCanBeRemoved() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result r = createChange();
-
-    // Add a new vote by user
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
-    requestScopeOperations.setApiUser(admin.id());
-
-    // Make a new patchset, keeping the Code-Review +1 vote.
-    amendChange(r.getChangeId());
-    assertVotes(detailedChange(r.getChangeId()), user, label, 1, null);
-
-    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
-
-    assertThat(r.getChange().notes().getApprovalsWithCopied()).isEmpty();
-
-    // Changes message has info about vote removed.
-    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
-        .contains("Code-Review+1 by User");
-  }
-
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -1501,6 +1759,21 @@
     assertThat(approval.copied()).isEqualTo(copied);
   }
 
+  private void updateCodeReviewLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.CODE_REVIEW, update);
+  }
+
+  private void updateVerifiedLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.VERIFIED, update);
+  }
+
+  private void updateLabel(String labelName, Consumer<LabelType.Builder> update) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(labelName, update);
+      u.save();
+    }
+  }
+
   /**
    * Test submit rule that always return a passing record with a "Verified" label applied by {@link
    * TestSubmitRule#userAccountId}.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 9825794..b9b77fe 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -34,6 +34,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -51,6 +53,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -66,6 +69,7 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.TestLabels;
@@ -94,6 +98,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Test
   public void submitRecords() throws Exception {
@@ -1048,8 +1053,7 @@
 
     // disable change index writes so that the change in the index gets stale when the new submit
     // requirement is added
-    disableChangeIndexWrites();
-    try {
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
       // Override submit requirement in project (allow uploaders to self approve).
       configSubmitRequirement(
           project,
@@ -1072,8 +1076,6 @@
       assertThat(actions).containsKey("submit");
       ActionInfo submitAction = actions.get("submit");
       assertThat(submitAction.enabled).isTrue();
-    } finally {
-      enableChangeIndexWrites();
     }
   }
 
@@ -1197,11 +1199,24 @@
 
       SubmitRequirementResult result =
           notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
-      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
-      assertThat(result.submittabilityExpressionResult().get().status())
-          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
-      assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
-          .isEqualTo("label:Code-Review=MAX");
+      assertSubmitRequirementResult(
+          result,
+          "Code-Review",
+          SubmitRequirementResult.Status.SATISFIED,
+          /* submitExpr= */ "label:Code-Review=MAX",
+          SubmitRequirementExpressionResult.Status.PASS);
+
+      // Adding comments does not affect the stored SRs.
+      addComment(r.getChangeId(), /* file= */ "foo");
+      notes = notesFactory.create(project, r.getChange().getId());
+      result = notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertSubmitRequirementResult(
+          result,
+          "Code-Review",
+          SubmitRequirementResult.Status.SATISFIED,
+          /* submitExpr= */ "label:Code-Review=MAX",
+          SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(notes.getHumanComments()).hasSize(1);
     }
   }
 
@@ -1881,13 +1896,19 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
     assertThat(requirement.submittabilityExpressionResult).isNotNull();
   }
 
   @Test
-  public void submitRequirement_nonApplicable_submittabilityAndOverrideNotEvaluated()
-      throws Exception {
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED)
+  public void
+      submitRequirement_nonApplicable_submittabilityAndOverrideNotEvaluatedIfExperimentEnabled()
+          throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
@@ -1914,6 +1935,49 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
+        /* fulfilled= */ false);
+    assertThat(requirement.submittabilityExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.submittabilityExpressionResult.expression)
+        .isEqualTo(SubmitRequirementExpression.maxCodeReview().expressionString());
+    assertThat(requirement.overrideExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.overrideExpressionResult.expression)
+        .isEqualTo("project:" + project.get());
+  }
+
+  @Test
+  public void
+      submitRequirement_nonApplicable_submittabilityAndOverrideAreEmptyIfExperimentNotEnabled()
+          throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of("branch:refs/heads/non-existent"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:" + project.get()))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertSubmitRequirementExpression(
+        requirement.applicabilityExpressionResult,
+        /* expression= */ null,
+        /* passingAtoms= */ null,
+        /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
     assertThat(requirement.submittabilityExpressionResult).isNull();
     assertThat(requirement.overrideExpressionResult).isNull();
@@ -1949,12 +2013,14 @@
         /* passingAtoms= */ ImmutableList.of(
             SubmitRequirementExpression.maxCodeReview().expressionString()),
         /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
     assertSubmitRequirementExpression(
         requirement.overrideExpressionResult,
         /* expression= */ "project:non-existent",
         /* passingAtoms= */ ImmutableList.of(),
         /* failingAtoms= */ ImmutableList.of("project:non-existent"),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
   }
 
@@ -1989,12 +2055,14 @@
         /* passingAtoms= */ ImmutableList.of(),
         /* failingAtoms= */ ImmutableList.of(
             SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
     assertSubmitRequirementExpression(
         requirement.overrideExpressionResult,
         /* expression= */ "project:" + project.get(),
         /* passingAtoms= */ ImmutableList.of("project:" + project.get()),
         /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
   }
 
@@ -2732,6 +2800,19 @@
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
 
+  private void assertSubmitRequirementResult(
+      SubmitRequirementResult result,
+      String srName,
+      SubmitRequirementResult.Status status,
+      String submitExpr,
+      SubmitRequirementExpressionResult.Status submitStatus) {
+    assertThat(result.submitRequirement().name()).isEqualTo(srName);
+    assertThat(result.status()).isEqualTo(status);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo(submitExpr);
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(submitStatus);
+  }
+
   private void assertSubmitRequirementStatus(
       Collection<SubmitRequirementResultInfo> results,
       String requirementName,
@@ -2783,6 +2864,7 @@
       @Nullable String expression,
       @Nullable List<String> passingAtoms,
       @Nullable List<String> failingAtoms,
+      SubmitRequirementExpressionInfo.Status status,
       boolean fulfilled) {
     assertThat(result.expression).isEqualTo(expression);
     if (passingAtoms == null) {
@@ -2795,6 +2877,7 @@
     } else {
       assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
     }
+    assertThat(result.status).isEqualTo(status);
     assertThat(result.fulfilled).isEqualTo(fulfilled);
   }
 
@@ -2864,4 +2947,14 @@
     input.overrideExpression = overrideIf;
     return input;
   }
+
+  private void addComment(String changeId, String file) throws Exception {
+    ReviewInput in = new ReviewInput();
+    CommentInput ci = new CommentInput();
+    ci.path = file;
+    ci.message = "message";
+    ci.line = 1;
+    in.comments = ImmutableMap.of("foo", ImmutableList.of(ci));
+    gApi.changes().id(changeId).current().review(in);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 29fdc15..651130e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -61,9 +61,9 @@
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
               value(0, "No score"),
-              value(-1, "I would prefer that you didn't submit this"),
-              value(-2, "Do not submit"));
-      codeReview.setCopyAnyScore(true);
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:ANY");
       u.getConfig().upsertLabelType(codeReview.build());
       u.save();
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
index 744cc2a..fe0ab2a 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.api.plugin;
 
+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.config.GerritConfig;
@@ -34,9 +36,9 @@
   @Override
   protected void afterTest() throws Exception {}
 
-  @Test(expected = MissingMandatoryPluginsException.class)
+  @Test
   @GerritConfig(name = "plugins.mandatory", value = "my-mandatory-plugin")
   public void shouldFailToStartGerritWhenMandatoryPluginsAreMissing() throws Exception {
-    super.beforeTest(testDescription);
+    assertThrows(MissingMandatoryPluginsException.class, () -> super.beforeTest(testDescription));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 18e192d..5a024cc 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -23,6 +23,7 @@
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -34,6 +35,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -43,10 +45,13 @@
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
@@ -433,6 +438,76 @@
     assertThat(changeInfo.topic).isEqualTo(input.topic);
   }
 
+  @Test
+  public void cherryPickOnTopOfOpenChange() throws Exception {
+    BranchNameKey srcBranch = BranchNameKey.create(project, "master");
+
+    // Create a target branch
+    BranchNameKey destBranch = BranchNameKey.create(project, "foo");
+    createBranch(destBranch);
+
+    // Create base change on the target branch
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch.shortName());
+    String base = r.getCommit().name();
+    int baseChangeNumber = r.getChange().getId().get();
+
+    // Create commit to cherry-pick on the source branch (no change exists for this commit)
+    String changeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    RevCommit commitToCherryPick;
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      commitToCherryPick =
+          tr.commit()
+              .parent(repo.parseCommit(repo.exactRef(srcBranch.branch()).getObjectId()))
+              .message(String.format("Commit to be cherry-picked\n\nChange-Id: %s\n", changeId))
+              .add("file.txt", "content")
+              .create();
+      tr.branch(srcBranch.branch()).update(commitToCherryPick);
+    }
+
+    // Perform the cherry-pick (cherry-pick on top of the base change)
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch.shortName();
+    input.base = base;
+    ChangeInfo cherryPickChange =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.name())
+            .cherryPick(input)
+            .get();
+
+    // Verify that a new change in destination branch was created.
+    assertThat(cherryPickChange._number).isGreaterThan(baseChangeNumber);
+    assertThat(cherryPickChange.branch).isEqualTo(destBranch.shortName());
+    assertThat(cherryPickChange.revisions).hasSize(1);
+    assertThat(cherryPickChange.messages).hasSize(1);
+
+    // Verify that the Change-Id of the cherry-picked commit is used for the cherry pick change.
+    assertThat(cherryPickChange.changeId).isEqualTo(changeId);
+
+    // Verify that cherry-pick-of is not set, since we cherry-picked a commit and not a change.
+    assertThat(cherryPickChange.cherryPickOfChange).isNull();
+    assertThat(cherryPickChange.cherryPickOfPatchSet).isNull();
+
+    // Verify that the message of the cherry-picked commit was used for the cherry-pick change.
+    RevisionInfo revInfo = cherryPickChange.revisions.get(cherryPickChange.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(commitToCherryPick.getFullMessage());
+
+    // Verify that the provided base commit is the parent commit of the cherry pick revision.
+    assertThat(revInfo.commit.parents).hasSize(1);
+    assertThat(revInfo.commit.parents.get(0).commit).isEqualTo(input.base);
+
+    // Verify that the related changes contain the base change and the cherry-pick change (no matter
+    // for which of these changes the related changes are retrieved).
+    assertThat(gApi.changes().id(cherryPickChange._number).current().related().changes)
+        .comparingElementsUsing(hasId())
+        .containsExactly(baseChangeNumber, cherryPickChange._number);
+    assertThat(gApi.changes().id(baseChangeNumber).current().related().changes)
+        .comparingElementsUsing(hasId())
+        .containsExactly(baseChangeNumber, cherryPickChange._number);
+  }
+
   private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
     return gApi.projects().name(project.get()).commit(id.name()).includedIn();
   }
@@ -452,4 +527,9 @@
   private void createLightWeightTag(String tagName) throws Exception {
     pushHead(testRepo, RefNames.REFS_TAGS + tagName, false, false);
   }
+
+  private static Correspondence<RelatedChangeAndCommitInfo, Integer> hasId() {
+    return NullAwareCorrespondence.transforming(
+        relatedChangeAndCommitInfo -> relatedChangeAndCommitInfo._changeNumber, "hasId");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
new file mode 100644
index 0000000..5531050
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -0,0 +1,691 @@
+// 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.acceptance.GitUtil.fetch;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class ProjectConfigIT extends AbstractDaemonTest {
+  private static final String INVALID_PRROJECT_CONFIG =
+      "[label \"Foo\"]\n"
+          // copyAllScoresOnTrivialRebase is deprecated and no longer allowed to be set
+          + "  copyAllScoresOnTrivialRebase = true";
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void noLabelValidationForNonRefsMetaConfigChange() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            "refs/heads/master",
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void noLabelValidationForNoneProjectConfigChange() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Test Change",
+            "foo.config",
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_push() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[label \"Foo\"]\n  description = Foo Label");
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_createChangeApi() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = RefNames.REFS_CONFIG;
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    ChangeInfo changeInfo = gApi.changes().create(changeInput).get();
+
+    gApi.changes().id(changeInfo.id).edit().create();
+    gApi.changes()
+        .id(changeInfo.id)
+        .edit()
+        .modifyFile(
+            ProjectConfig.PROJECT_CONFIG,
+            RawInputUtil.create("[label \"Foo\"]\n  description = Foo Label"));
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeInfo.id).edit().publish(publishInput);
+
+    approve(changeInfo.id);
+    gApi.changes().id(changeInfo.id).current().submit();
+    assertThat(gApi.changes().id(changeInfo.id).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void rejectSettingCopyAnyScore() throws Exception {
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectSettingCopyMinScore() throws Exception {
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectSettingCopyMaxScore() throws Exception {
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoChange() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true, "changekind:NO_CHANGE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false, "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectSettingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use '%s' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), key, expectedPredicateSuggestion));
+  }
+
+  @Test
+  public void rejectSettingCopyValues() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), ProjectConfig.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void rejectChangingCopyAnyScore() throws Exception {
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectChangingCopyMinScore() throws Exception {
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectChangingCopyMaxScore() throws Exception {
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoChange() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true, "changekind:NO_CHANGE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false, "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectChangingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, !value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    testRejectSettingLabelFlag(key, value, expectedPredicateSuggestion);
+  }
+
+  @Test
+  public void rejectChangingCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = -1\n  %s = -2",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), ProjectConfig.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void unsetCopyAnyScore() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMinScore() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMaxScore() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoChange() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ false);
+  }
+
+  private void testUnsetLabelFlag(String key, boolean previousValue) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, previousValue))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void unsetCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyAnyScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMinScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMaxScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoCodeChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnMergeFirstParentUpdateUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnTrivialRebaseUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfListOfFilesDidNotChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, /* value= */ false);
+  }
+
+  private void testKeepLabelFlagUnchanged(String key, boolean value) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG, String.format("[label \"Foo\"]\n  %s = %s", key, value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s\n  otherKey = value", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2\n  otherKey = value",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged_differentOrder() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 2\n  %s = 1",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void rejectMultipleLabelFlags() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = true\n  %s = true",
+                ProjectConfig.KEY_COPY_MIN_SCORE, ProjectConfig.KEY_COPY_MAX_SCORE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MIN' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), ProjectConfig.KEY_COPY_MIN_SCORE));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MAX' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), ProjectConfig.KEY_COPY_MAX_SCORE));
+  }
+
+  @Test
+  public void setCopyCondition() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = is:ANY", ProjectConfig.KEY_COPY_CONDITION));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void validateLabelConfigInInitialCommit() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    PushOneCommit push =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Test Change",
+                ProjectConfig.PROJECT_CONFIG,
+                INVALID_PRROJECT_CONFIG)
+            .setParents(ImmutableList.of());
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private String abbreviateName(AnyObjectId id) throws Exception {
+    return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 9c74c48..58d1628 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -928,6 +928,21 @@
   }
 
   @Test
+  public void invalidJgitConfigFile_canNotPushToProjectConfig() throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(allProjects);
+    GitUtil.fetch(repo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    repo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), repo, "Subject", "project.config", "jgit config")
+            .to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus("invalid project configuration");
+    r.assertMessage(
+        "Invalid config file project.config in project All-Projects in branch refs/meta/config");
+    r.assertMessage("Invalid line in config file");
+  }
+
+  @Test
   public void getProjectThatHasLabelDefinitionWithDuplicateValues() throws Exception {
     // Update the definition of the Code-Review label so that it has the value "+1 LGTM" twice.
     // This update bypasses all validation checks so that the duplicate label value doesn't get
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index e45d95c..a625a70 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -21,6 +21,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -50,6 +51,7 @@
   @Inject private IndexConfig indexConfig;
   @Inject private StalenessChecker stalenessChecker;
   @Inject private ProjectOperations projectOperations;
+  @Inject private IndexOperations.Project projectIndexOperations;
 
   private static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
@@ -124,7 +126,7 @@
         assertThrows(
             StorageException.class,
             () -> {
-              try (AutoCloseable ignored = disableProjectIndex()) {
+              try (AutoCloseable ignored = projectIndexOperations.disableReadsAndWrites()) {
                 try (ProjectConfigUpdate u = updateProject(project)) {
                   update.accept(u.getConfig());
                   u.save();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index dcd274d..efd3cea 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -197,6 +198,26 @@
   }
 
   @Test
+  public void gitwebFileWebLinkIncludedInDiff() throws Exception {
+    try (Registration registration = newGitwebFileWebLink()) {
+      String fileName = "foo.txt";
+      String fileContent = "bar\n";
+      PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+      DiffInfo info =
+          gApi.changes()
+              .id(result.getChangeId())
+              .revision(result.getCommit().name())
+              .file(fileName)
+              .diff();
+      assertThat(info.metaB.webLinks).hasSize(1);
+      assertThat(info.metaB.webLinks.get(0).url)
+          .isEqualTo(
+              String.format(
+                  "http://gitweb/?p=%s;hb=%s;f=%s", project, result.getCommit().name(), fileName));
+    }
+  }
+
+  @Test
   public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
@@ -2965,6 +2986,21 @@
     return extensionRegistry.newRegistration().add(webLink);
   }
 
+  private Registration newGitwebFileWebLink() {
+    FileWebLink fileWebLink =
+        new FileWebLink() {
+          @Override
+          public WebLinkInfo getFileWebLink(
+              String projectName, String revision, String hash, String fileName) {
+            return new WebLinkInfo(
+                "name",
+                "imageURL",
+                String.format("http://gitweb/?p=%s;hb=%s;f=%s", projectName, hash, fileName));
+          }
+        };
+    return extensionRegistry.newRegistration().add(fileWebLink);
+  }
+
   private String updatedCommitMessage() {
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 0e0168e..9fae6c0 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -810,7 +811,9 @@
       LabelType codeReview = TestLabels.codeReview();
       u.getConfig().upsertLabelType(codeReview);
       u.getConfig()
-          .updateLabelType(codeReview.getName(), lt -> lt.setCopyAllScoresIfNoCodeChange(true));
+          .updateLabelType(
+              codeReview.getName(),
+              lt -> lt.setCopyCondition("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index ba1e1a7..dd4b1e4 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1062,6 +1062,9 @@
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 1: Code-Review+1.");
 
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
+    assertThatUserIsOnlyReviewer(ci, admin);
+
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -1071,12 +1074,13 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1092,6 +1096,7 @@
             "moreContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
     ci = get(r.getChangeId(), MESSAGES);
     assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
   }
@@ -1110,6 +1115,7 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
@@ -1117,7 +1123,7 @@
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
 
     // Check that the user who pushed the new patch set was added as a reviewer since they added
-    // a vote
+    // a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1244,7 +1250,7 @@
     assertThat(cr.all).hasSize(1);
     cr = ci.labels.get("Custom-Label");
     assertThat(cr.all).hasSize(1);
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
   }
 
@@ -1936,7 +1942,7 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyMaxScore(true).build();
+      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyCondition("is:MAX").build();
       u.getConfig().upsertLabelType(codeReview);
       u.save();
     }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 31292d5..0cdac5a 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Address;
@@ -65,6 +66,7 @@
   }
 
   @Test
+  @UseLocalDisk
   public void submitOnPush() throws Exception {
     projectOperations
         .project(project)
@@ -76,6 +78,8 @@
     r.assertChange(Change.Status.MERGED, null, admin);
     assertSubmitApproval(r.getPatchSetId());
     assertCommit(project, "refs/heads/master");
+    assertThat(gApi.projects().name(project.get()).branch("master").reflog().get(0).comment)
+        .isEqualTo("forced-merge");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 0905587..f58f81c 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
@@ -82,6 +83,7 @@
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   private AccountGroup.UUID admins;
   private AccountGroup.UUID nonInteractiveUsers;
@@ -1395,7 +1397,7 @@
   public void fetchSingleChangeWithoutIndexAccess() throws Exception {
     PushOneCommit.Result change = createChange();
     String patchSetRef = change.getPatchSetId().toRefName();
-    try (AutoCloseable ignored = disableChangeIndex();
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
         Repository repo = repoManager.openRepository(project)) {
       ImmutableList<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
       ImmutableList<Ref> filteredRefs =
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index 9c5afd2..3cf54f3 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -25,6 +27,7 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE_NONFASTFORWARD;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
@@ -79,6 +82,35 @@
   }
 
   @Test
+  public void infoMessagesAreReturnedOnPush() throws Exception {
+    String message1 = "for bar baz";
+    String message2 = "abc xyz";
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new RefOperationValidationListener() {
+                  @Override
+                  public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+                      throws ValidationException {
+                    return ImmutableList.of(
+                        new ValidationMessage(message1, ValidationMessage.Type.HINT),
+                        new ValidationMessage(message2, ValidationMessage.Type.HINT));
+                  }
+                })) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of());
+      assertPushOk(r, "refs/heads/new");
+      assertThat(r.getMessages()).contains(String.format("hint: %s\nhint: %s", message1, message2));
+    }
+  }
+
+  @Test
   public void rejectRefCreation() throws Exception {
     try (Registration registration = testValidator(CREATE)) {
       RestApiException expected =
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 1c8ca93..d2aab5b 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -53,6 +54,8 @@
   }
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private IndexOperations.Account accountIndexOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Test
@@ -747,12 +750,10 @@
   }
 
   private String getStatus(ChangeData cd) throws Exception {
-
-    try (AutoCloseable changeIndex = disableChangeIndex()) {
-      try (AutoCloseable accountIndex = disableAccountIndex()) {
-        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-        return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
-      }
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
+        AutoCloseable accountIndex = accountIndexOperations.disableReadsAndWrites()) {
+      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+      return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cac376f..7386a03 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -32,10 +32,12 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -48,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
+import org.junit.Assume;
 import org.junit.Test;
 
 @NoHttpd
@@ -175,7 +178,9 @@
 
   @Test
   public void onlineUpgradeChanges() throws Exception {
-    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
+    Schema<ChangeData> previous = ChangeSchemaDefinitions.INSTANCE.getPrevious();
+    Assume.assumeNotNull(previous);
+    int prevVersion = previous.getVersion();
     int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
 
     // Before storing any changes, switch back to the previous version.
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
new file mode 100644
index 0000000..2c2e57d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
@@ -0,0 +1,829 @@
+// 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.pgm;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.schema.MigrateLabelConfigToCopyCondition;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MigrateLabelConfigToCopyConditionIT extends AbstractDaemonTest {
+  private static final ImmutableSet<String> DEPRECATED_FIELDS =
+      ImmutableSet.<String>builder()
+          .add(ProjectConfig.KEY_COPY_ANY_SCORE)
+          .add(ProjectConfig.KEY_COPY_MIN_SCORE)
+          .add(ProjectConfig.KEY_COPY_MAX_SCORE)
+          .add(ProjectConfig.KEY_COPY_VALUE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
+          .build();
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      u.save();
+    }
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // overwrite the default value for copyAllScoresIfNoChange which is true
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.VERIFIED,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void nothingToMigrate_noLabels() throws Exception {
+    Project.NameKey projectWithoutLabelDefinitions = projectOperations.newProject().create();
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG);
+
+    runMigration(projectWithoutLabelDefinitions);
+
+    // verify that refs/meta/config was not touched
+    assertThat(
+            projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void noFieldsToMigrate() throws Exception {
+    assertThat(projectOperations.project(project).getConfig().getSubsections(ProjectConfig.LABEL))
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+
+    // copyAllScoresIfNoChange=false is set in the test setup to override the default value
+    assertDeprecatedFieldsUnset(
+        LabelId.CODE_REVIEW, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+  }
+
+  @Test
+  public void noFieldsToMigrate_copyConditionExists() throws Exception {
+    String copyCondition = "is:MIN";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_complexCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. no parentheses have been added around
+    // the
+    // copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_nonOrderedCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN OR has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. the order of OR conditions has not be
+    // changed and no parentheses have been added around the copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void migrateCopyAnyScore() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_ANY_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:ANY"));
+  }
+
+  @Test
+  public void migrateCopyMinScore() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_MIN_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MIN"));
+  }
+
+  @Test
+  public void migrateCopyMaxScore() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_MAX_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MAX"));
+  }
+
+  @Test
+  public void migrateCopyValues_singleValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(1), copyCondition -> assertThat(copyCondition).isEqualTo("is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_negativeValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1), copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\""));
+  }
+
+  @Test
+  public void migrateCopyValues_multipleValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1, 1),
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_manyValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-2, -1, 1, 2),
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:\"-2\" OR is:1 OR is:2"));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCange() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("changekind:" + ChangeKind.NO_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCodeCange() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.NO_CODE_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnTrivialRebase() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.TRIVIAL_REBASE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("has:unchanged-files"));
+  }
+
+  @Test
+  public void migrateDefaultValues() throws Exception {
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition was set to "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void migrateDefaultValues_copyConditionExists() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:MIN");
+
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition includes "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+  }
+
+  @Test
+  public void migrateAll() throws Exception {
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MAX_SCORE);
+    setCopyValuesOnCodeReviewLabel(-2, -1, 1, 2);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "changekind:MERGE_FIRST_PARENT_UPDATE"
+                + " OR changekind:NO_CHANGE"
+                + " OR changekind:NO_CODE_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE"
+                + " OR has:unchanged-files"
+                + " OR is:\"-1\""
+                + " OR is:\"-2\""
+                + " OR is:1"
+                + " OR is:2"
+                + " OR is:ANY"
+                + " OR is:MAX"
+                + " OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_mutualllyExclusive() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicate()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicates()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY OR is:MIN");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MAX OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v1()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY changekind:TRIVIAL_REBASE");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v2()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel(
+        "is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "(is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noUnnecessaryParenthesesAdded()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("(is:ANY changekind:TRIVIAL_REBASE)");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_existingCopyConditionIsNotParseable()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT-PARSEABLE");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("NOT-PARSEABLE OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void
+      migrationMergesFlagsIntoExistingCopyCondition_existingComplexCopyConditionIsNotParseable()
+          throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT PARSEABLE");
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(NOT PARSEABLE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrateMultipleLabels() throws Exception {
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    setFlagOnVerifiedLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnVerifiedLabel(ProjectConfig.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+    assertThat(getCopyConditionOfVerifiedLabel()).isEqualTo("changekind:TRIVIAL_REBASE OR is:MAX");
+  }
+
+  @Test
+  public void deprecatedFlagsThatAreSetToFalseAreUnset() throws Exception {
+    // set all flags to false
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ANY_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_MIN_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_MAX_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void emptyCopyValueParameterIsUnset() throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                ProjectConfig.KEY_COPY_VALUE,
+                /* value= */ ""));
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void migrationCreatesASingleCommit() throws Exception {
+    // Set flags on 2 labels (the migrations for both labels are expected to be done in a single
+    // commit)
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnVerifiedLabel(ProjectConfig.KEY_COPY_MAX_SCORE);
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that the new commit in refs/meta/config is a successor of the old head
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getParent(0))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void commitMessageIsDistinct() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    // Verify that the commit message is distinct (e.g. this is important in case there is an issue
+    // with the migration, having a distinct commit message allows to identify the commit that was
+    // done for the migration and would allow to revert it)
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getShortMessage())
+        .isEqualTo(MigrateLabelConfigToCopyCondition.COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void gerritIsAuthorAndCommitterOfTheMigrationCommit() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getAuthorIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(refsMetaConfigHead.getAuthorIdent().getName())
+        .isEqualTo(serverIdent.get().getName());
+    assertThat(refsMetaConfigHead.getCommitterIdent())
+        .isEqualTo(refsMetaConfigHead.getAuthorIdent());
+  }
+
+  @Test
+  public void migrationFailsIfProjectConfigIsNotParseable() throws Exception {
+    projectOperations.project(project).forInvalidation().makeProjectConfigInvalid().invalidate();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    ConfigInvalidException exception =
+        assertThrows(ConfigInvalidException.class, () -> runMigration());
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(String.format("Invalid config file project.config in project %s", project));
+
+    // verify that refs/meta/config was not touched
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenProjectConfigIsMissing() throws Exception {
+    deleteProjectConfig();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that refs/meta/config was not touched (e.g. project.config was not created)
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenRefsMetaConfigIsMissing() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    runMigration();
+
+    // verify that refs/meta/config was not created
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      assertThat(testRepo.getRepository().exactRef(RefNames.REFS_CONFIG)).isNull();
+    }
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsUnset() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ null);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsFalse() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ false);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsTrue() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ true);
+  }
+
+  private void testMigrationIsIdempotent(@Nullable Boolean copyAllScoresIfNoChangeValue)
+      throws Exception {
+    updateProjectConfig(
+        cfg -> {
+          if (copyAllScoresIfNoChangeValue != null) {
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+          } else {
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+          }
+        });
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+
+    // default value for copyAllScoresIfNoChangeValue is true
+    if (copyAllScoresIfNoChangeValue == null || copyAllScoresIfNoChangeValue) {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+    } else {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isNull();
+    }
+
+    // Running the migration again doesn't change anything.
+    RevCommit head = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    runMigration();
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG)).isEqualTo(head);
+  }
+
+  private void testFlagMigration(String key, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setFlagOnCodeReviewLabel(key);
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void testCopyValueMigration(List<Integer> values, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setCopyValuesOnCodeReviewLabel(values.toArray(new Integer[0]));
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void runMigration() throws Exception {
+    runMigration(project);
+  }
+
+  private void runMigration(Project.NameKey project) throws Exception {
+    new MigrateLabelConfigToCopyCondition(repoManager, serverIdent.get()).execute(project);
+  }
+
+  private void setFlagOnCodeReviewLabel(String key) throws Exception {
+    setFlag(LabelId.CODE_REVIEW, key);
+  }
+
+  private void setFlagOnVerifiedLabel(String key) throws Exception {
+    setFlag(LabelId.VERIFIED, key);
+  }
+
+  private void setFlag(String labelName, String key) throws Exception {
+    updateProjectConfig(
+        cfg -> cfg.setBoolean(ProjectConfig.LABEL, labelName, key, /* value= */ true));
+  }
+
+  private void unset(String labelName, String key) throws Exception {
+    updateProjectConfig(cfg -> cfg.unset(ProjectConfig.LABEL, labelName, key));
+  }
+
+  private void setCopyValuesOnCodeReviewLabel(Integer... values) throws Exception {
+    setCopyValues(LabelId.CODE_REVIEW, values);
+  }
+
+  private void setCopyValues(String labelName, Integer... values) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setStringList(
+                ProjectConfig.LABEL,
+                labelName,
+                ProjectConfig.KEY_COPY_VALUE,
+                Arrays.stream(values).map(Object::toString).collect(toImmutableList())));
+  }
+
+  private void setCopyConditionOnCodeReviewLabel(String copyCondition) throws Exception {
+    setCopyCondition(LabelId.CODE_REVIEW, copyCondition);
+  }
+
+  private void setCopyCondition(String labelName, String copyCondition) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, copyCondition));
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    Config projectConfig = projectOperations.project(project).getConfig();
+    configUpdater.accept(projectConfig);
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void deleteProjectConfig() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .rm(ProjectConfig.PROJECT_CONFIG));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void assertDeprecatedFieldsUnset(String labelName, String... excludedFields) {
+    for (String field :
+        Sets.difference(DEPRECATED_FIELDS, Sets.newHashSet(Arrays.asList(excludedFields)))) {
+      assertUnset(labelName, field);
+    }
+  }
+
+  private void assertUnset(String labelName, String key) {
+    assertThat(
+            projectOperations.project(project).getConfig().getNames(ProjectConfig.LABEL, labelName))
+        .doesNotContain(key);
+  }
+
+  private String getCopyConditionOfCodeReviewLabel() {
+    return getCopyCondition(LabelId.CODE_REVIEW);
+  }
+
+  private String getCopyConditionOfVerifiedLabel() {
+    return getCopyCondition(LabelId.VERIFIED);
+  }
+
+  private String getCopyCondition(String labelName) {
+    return projectOperations
+        .project(project)
+        .getConfig()
+        .getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 093711f..fd9054c 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// 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.
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 7e40b2b..64e3762 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -374,6 +374,7 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "true")
   public void performanceLoggingForRestCall() throws Exception {
     PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
@@ -385,6 +386,7 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "true")
   public void performanceLoggingForPush() throws Exception {
     PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
@@ -397,8 +399,7 @@
   }
 
   @Test
-  @GerritConfig(name = "tracing.performanceLogging", value = "false")
-  public void noPerformanceLoggingIfDisabled() throws Exception {
+  public void noPerformanceLoggingByDefault() throws Exception {
     PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index de14d00..8eada79 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
@@ -1446,7 +1447,7 @@
       fmt.setRepository(repo);
       fmt.format(oldTreeId, newTreeId);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 9fcce3d..fbcc5fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -104,7 +104,8 @@
 
   @Test
   public void revisionActionsTwoChangesInTopic() throws Exception {
-    String changeId = createChangeWithTopic().getChangeId();
+    PushOneCommit.Result change1 = createChangeWithTopic();
+    String changeId = change1.getChangeId();
     approve(changeId);
     PushOneCommit.Result change2 = createChangeWithTopic();
     int legacyId2 = change2.getChange().getId().get();
@@ -117,8 +118,12 @@
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
       assertThat(info.title)
-          .matches(
+          .startsWith(
               "Change "
+                  + change1.getChange().getId()
+                  + " must be submitted with change "
+                  + legacyId2
+                  + " but "
                   + legacyId2
                   + " is not ready: submit requirement 'Code-Review' is unsatisfied.");
     } else {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index b034a42..ea52690 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
@@ -42,8 +46,11 @@
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
@@ -60,11 +67,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.GetAttentionSet;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -1665,11 +1674,7 @@
 
     // Add preference for the user such that they only receive an email on changes that require
     // their attention.
-    requestScopeOperations.setApiUser(user.id());
-    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
-    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
-    gApi.accounts().self().setPreferences(prefs);
-    requestScopeOperations.setApiUser(admin.id());
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
 
     // Add user to attention set. They receive an email since they are in the attention set.
     change(r).addReviewer(user.id().toString());
@@ -1743,11 +1748,7 @@
   public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
     // Add preference for the user such that they only receive an email on changes that require
     // their attention.
-    requestScopeOperations.setApiUser(user.id());
-    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
-    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
-    gApi.accounts().self().setPreferences(prefs);
-    requestScopeOperations.setApiUser(admin.id());
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
 
     // Ensure emails that don't relate to changes are still sent.
     gApi.accounts().id(user.id().get()).generateHttpPassword();
@@ -2024,6 +2025,736 @@
         .isEqualTo(Operation.REMOVE);
   }
 
+  @Test
+  public void outsideAttentionSet_watchProjectEmailReceived() throws Exception {
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
+
+    requestScopeOperations.setApiUser(user.id());
+    watch(project.get());
+
+    createChange();
+
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+  }
+
+  @Test
+  public void approverOfOutdatedApprovalAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval that gets outdated when a new patch set is created (i.e. an approval that is
+    // not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approval got outdated and was removed and
+    // user now needs to re-review the change and renew the approval.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"));
+
+    // Expect that the email notification contains the outdated vote.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s\n",
+                user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                user.fullName()));
+  }
+
+  @Test
+  public void approverOfMultipleOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create a Verify and a Foo-Var label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add multiple approvals from one user that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", -1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Votes got outdated and were removed: Code-Review+1, Foo-Bar-1, Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s\n",
+                user.fullName(), user.fullName(), user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user.fullName(), user.fullName()));
+  }
+
+  @Test
+  public void multipleApproverOfOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create Verify label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add approvals from multiple users that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user2.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "Hello %s, %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user2.fullName(), user.fullName(), user2.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s\n",
+                user.fullName(), user2.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s, %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), user2.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user2.fullName()));
+  }
+
+  @Test
+  public void robotApproverOfOutdatedApprovalIsNotAddedToAttentionSet() throws Exception {
+    // Create robot account
+    TestAccount robot =
+        accountCreator.create(
+            "robot-X",
+            "robot-x@example.com",
+            "Ro Bot X",
+            "RoX",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval by a robot that gets outdated when a new patch set is created (i.e. an
+    // approval that is not copied).
+    requestScopeOperations.setApiUser(robot.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // A robot vote doesn't add the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet()).isEmpty();
+
+    // Amend the change, this removes the vote from the robot, as it is not copied to the new patch
+    // set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // The robot was not added to the attention set because users service users are never added to
+    // the attention set.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    String emailBody = message.body();
+    assertThat(emailBody)
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit",
+                robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\nCode-Review+1 by %s",
+                robot.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                robot.fullName()));
+  }
+
+  @Test
+  public void approverOfCopiedApprovelNotAddedToAttentionSet() throws Exception {
+    // Allow user to make veto votes.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add a veto vote that will be copied over to a new patch set.
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, -2));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this copies the vote from user to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been copied.
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Attention set wasn't changed.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit", user.fullName()));
+    assertThat(message.body())
+        .doesNotContain("The following approvals got outdated and were removed:");
+    assertThat(message.htmlBody())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain("The following approvals got outdated and were removed:");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalRemoved()
+      throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Removing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void
+      ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalDowngraded()
+          throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Changing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review+1\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_vetoApplied()
+      throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Adding the veto approval added the owner (admin) and the uploader (user) to the attention
+    // set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review-2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  private void setEmailStrategyForUser(EmailStrategy es) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = es;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+  }
+
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
     return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
new file mode 100644
index 0000000..1094a42
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
@@ -0,0 +1,910 @@
+// 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.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+/**
+ * Integration test to verify that change-no-longer-submittable emails are sent when a change
+ * becomes not submittable, and that they are sent only in this case (and not when the change
+ * becomes submittable or stays submittable/unsubmittable).
+ */
+public class ChangeNoLongerSubmittableIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notifications that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_vetoApplied() throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_multipleSubmitRequirementsNoLongerSatisfied()
+      throws Exception {
+    // Create a Verify, a Foo-Bar and a Bar-Baz label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      LabelType.Builder barBaz =
+          labelBuilder("Bar-Baz", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(barBaz.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Bar-Baz")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve all labels.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Bar-Baz", 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke several approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 0).label("Foo-Bar", 0).label("Verified", 0));
+
+    // Verify the email notification that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains(
+            "The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains(
+            "<p>The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke one approval.
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade one approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysUnsubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that doesn't make the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeBecomesSubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that makes the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    approve(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalNotCopied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    r.assertMessage(
+        "The following approvals got outdated and were removed:\n* Code-Review+2 by user2\n");
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndRevoked()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but revoke it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=-Code-Review",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndDowngraded()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but downgrade it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+1",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedVetoApplied()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but apply a new veto on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review-2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalCopied() throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set, the approval is copied.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalReapplied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval, but re-apply a new approval on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysUnsubmittable() throws Exception {
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesSubmittable() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set and approve it.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
index 15e6360..e2f4b5b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -135,7 +136,7 @@
               bufferedOut.write(data, 0, count);
             }
             bufferedOut.flush();
-            archiveEntries.put(entry.getName(), out.toString());
+            archiveEntries.put(entry.getName(), out.toString(UTF_8));
           }
         }
       }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index b3592e3..c712b14 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -414,8 +414,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -446,7 +445,7 @@
     // during submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index 2eade27..d58ad11 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -170,8 +170,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -202,7 +201,7 @@
     // submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index e9aa589..71ee90c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -40,8 +40,8 @@
     ImmutableMap<String, String> want =
         ImmutableMap.of(
             " 0", "No score",
-            "-1", "I would prefer this is not merged as is",
-            "-2", "This shall not be merged",
+            "-1", "I would prefer this is not submitted as is",
+            "-2", "This shall not be submitted",
             "+1", "Looks good to me, but someone else must approve",
             "+2", "Looks good to me, approved");
     assertThat(l.values).isEqualTo(want);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 9e31026..7f36692 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
@@ -35,21 +36,26 @@
             " 0",
             "No score",
             "-1",
-            "I would prefer this is not merged as is",
+            "I would prefer this is not submitted as is",
             "-2",
-            "This shall not be merged");
+            "This shall not be submitted");
     assertThat(codeReviewLabel.defaultValue).isEqualTo(0);
     assertThat(codeReviewLabel.branches).isNull();
     assertThat(codeReviewLabel.canOverride).isTrue();
     assertThat(codeReviewLabel.copyAnyScore).isNull();
-    assertThat(codeReviewLabel.copyMinScore).isTrue();
+    assertThat(codeReviewLabel.copyMinScore).isNull();
     assertThat(codeReviewLabel.copyMaxScore).isNull();
     assertThat(codeReviewLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isNull();
     assertThat(codeReviewLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
     assertThat(codeReviewLabel.copyValues).isNull();
+    assertThat(codeReviewLabel.copyCondition)
+        .isEqualTo(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertThat(codeReviewLabel.allowPostSubmit).isTrue();
     assertThat(codeReviewLabel.ignoreSelfApproval).isNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
index e1b4ccb..8405b25 100644
--- a/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
+++ b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.acceptance.server.approval;
 
 import static com.google.common.truth.Truth.assertThat;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
new file mode 100644
index 0000000..acde8f6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -0,0 +1,415 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.assertThatList;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.hasTestId;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.util.Set;
+import java.util.function.Predicate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests of the {@link ApprovalCopier} API.
+ *
+ * <p>This class doesn't verify the copy condition predicates, as they are already covered by {@code
+ * StickyApprovalsIT}.
+ */
+@NoHttpd
+public class ApprovalCopierIT extends AbstractDaemonTest {
+  @Inject private ApprovalCopier approvalCopier;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    // Add Verified label.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+                  LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"))
+              .setCopyCondition("is:MIN");
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+
+    // Grant permissions to vote on the verified label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  @Test
+  public void forInitialPatchSet_noApprovals() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThat(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forInitialPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forPatchSet_noApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThat(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forPatchSet_outdatedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, 1);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThat(approvalCopierResult.outdatedApprovals())
+          .comparingElementsUsing(hasTestId())
+          .containsExactly(
+              PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, 2),
+              PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.VERIFIED, 1));
+    }
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThatList(approvalCopierResult.copiedApprovals())
+          .comparingElementsUsing(hasTestId())
+          .containsExactly(
+              PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+              PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+      assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forPatchSet_allKindOfApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, 1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, -1);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThatList(approvalCopierResult.copiedApprovals())
+          .comparingElementsUsing(hasTestId())
+          .containsExactly(
+              PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+              PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+      assertThatList(approvalCopierResult.outdatedApprovals())
+          .comparingElementsUsing(hasTestId())
+          .containsExactly(
+              PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.CODE_REVIEW, 1),
+              PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.VERIFIED, 1));
+    }
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovalOverriddenByCurrentApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approval that is copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Override the copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forPatchSet_approvalForNonExistingLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add approval that could be copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    // Delete the Code-Review label (override it with an empty label definition).
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(labelBuilder(LabelId.CODE_REVIEW).build());
+      u.save();
+    }
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThatList(approvalCopierResult.outdatedApprovals())
+          .comparingElementsUsing(hasTestId())
+          .containsExactly(
+              PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+    }
+  }
+
+  @Test
+  public void copiedFlagSetOnCopiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Override copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    // Add new current approval.
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ImmutableSet<PatchSetApproval> copiedApprovals =
+          approvalCopier
+              .forPatchSet(
+                  changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig())
+              .copiedApprovals();
+      assertThatList(filter(copiedApprovals, PatchSetApproval::copied))
+          .comparingElementsUsing(hasTestId())
+          .containsExactly(
+              PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+      assertThatList(filter(copiedApprovals, psa -> !psa.copied())).isEmpty();
+    }
+  }
+
+  private void vote(String changeId, TestAccount testAccount, String label, int value)
+      throws RestApiException {
+    requestScopeOperations.setApiUser(testAccount.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, value));
+    requestScopeOperations.setApiUser(admin.id());
+  }
+
+  private ImmutableSet<PatchSetApproval> filter(
+      Set<PatchSetApproval> approvals, Predicate<PatchSetApproval> filter) {
+    return approvals.stream().filter(filter).collect(toImmutableSet());
+  }
+
+  public static class PatchSetApprovalSubject extends Subject {
+    public static Correspondence<PatchSetApproval, PatchSetApprovalTestId> hasTestId() {
+      return NullAwareCorrespondence.transforming(PatchSetApprovalTestId::create, "has test ID");
+    }
+
+    public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
+      return assertAbout(patchSetApprovals()).that(patchSetApproval);
+    }
+
+    public static ListSubject<PatchSetApprovalSubject, PatchSetApproval> assertThatList(
+        ImmutableSet<PatchSetApproval> patchSetApprovals) {
+      return ListSubject.assertThat(patchSetApprovals.asList(), patchSetApprovals());
+    }
+
+    private static Factory<PatchSetApprovalSubject, PatchSetApproval> patchSetApprovals() {
+      return PatchSetApprovalSubject::new;
+    }
+
+    private PatchSetApprovalSubject(FailureMetadata metadata, PatchSetApproval patchSetApproval) {
+      super(metadata, patchSetApproval);
+    }
+  }
+
+  /**
+   * AutoValue class that contains all properties of a PatchSetApproval that are relevant to do
+   * assertions in tests (patch set ID, account ID, label name, voting value).
+   */
+  @AutoValue
+  public abstract static class PatchSetApprovalTestId {
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract Account.Id accountId();
+
+    public abstract LabelId labelId();
+
+    public abstract short value();
+
+    public static PatchSetApprovalTestId create(PatchSetApproval patchSetApproval) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetApproval.patchSetId(),
+          patchSetApproval.accountId(),
+          patchSetApproval.labelId(),
+          patchSetApproval.value());
+    }
+
+    public static PatchSetApprovalTestId create(
+        PatchSet.Id patchSetId, Account.Id accountId, String labelId, int value) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetId, accountId, LabelId.create(labelId), (short) value);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index fd3ac7f..8bf7443 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -82,6 +83,7 @@
   @Inject private GroupOperations groupOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Inject private IndexConfig indexConfig;
   @Inject private ChangesCollection changes;
@@ -545,11 +547,8 @@
     RevCommit c2_2 = testRepo.amend(c2_1).add("b.txt", "2").create();
     testRepo.reset(c2_2);
 
-    disableChangeIndexWrites();
-    try {
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
       pushHead(testRepo, "refs/for/master", false);
-    } finally {
-      enableChangeIndexWrites();
     }
 
     PatchSet.Id psId1_1 = getPatchSetId(c1_1);
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index 09e6dfe..b2a0ded 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -65,7 +65,7 @@
       values = {"enabledFeature"})
   @GerritConfig(
       name = "experiments.disabled",
-      values = {"UiFeature__patchset_comments"})
+      values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
   public void configOverride_defaultFeatureDisabled() {
     assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
     assertThat(
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 85238f8..b94996c 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -33,6 +33,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -56,14 +57,16 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
 import com.google.inject.Inject;
+import java.util.UUID;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -599,6 +602,7 @@
   }
 
   private interface Adder {
+
     void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
         throws Exception;
   }
@@ -950,13 +954,13 @@
     // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
     StagedChange sc = stageWipChange();
     ReviewInput in =
-        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+        ReviewInput.noScore().message(PostReviewOp.START_REVIEW_MESSAGE).setWorkInProgress(false);
     gApi.changes().id(sc.changeId).current().review(in);
     Truth.assertThat(sender.getMessages()).isNotEmpty();
     String body = sender.getMessages().get(0).body();
-    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+    int idx = body.indexOf(PostReviewOp.START_REVIEW_MESSAGE);
     Truth.assertThat(idx).isAtLeast(0);
-    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+    Truth.assertThat(body.indexOf(PostReviewOp.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
   }
 
   private void review(TestAccount account, String changeId, EmailStrategy strategy)
@@ -993,13 +997,22 @@
     StagedPreChange spc = stagePreChange("refs/for/master");
     assertThat(sender)
         .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
   }
 
   @Test
+  public void verifyTitle() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .title(String.format("[S] Change in %s[master]: test commit", project));
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
   public void createWipChange() throws Exception {
     stagePreChange("refs/for/master%wip");
     assertThat(sender).didNotSend();
@@ -1060,7 +1073,7 @@
     StagedPreChange spc = stagePreChange("refs/for/master%wip,notify=ALL");
     assertThat(sender)
         .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -1073,10 +1086,13 @@
             "refs/for/master",
             users ->
                 ImmutableList.of("r=" + users.reviewer.username(), "cc=" + users.ccer.username()));
-    FakeEmailSenderSubject subject =
-        assertThat(sender).sent("newchange", spc).to(spc.reviewer, spc.watchingProjectOwner);
-    subject.cc(spc.ccer);
-    subject.bcc(NEW_CHANGES, NEW_PATCHSETS).noOneElse();
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.reviewer)
+        .cc(spc.ccer)
+        .bcc(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -1090,8 +1106,8 @@
     assertThat(sender)
         .sent("newchange", spc)
         .to("nobody1@example.com")
-        .to(spc.watchingProjectOwner)
         .cc("nobody2@example.com")
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -1288,6 +1304,7 @@
   }
 
   private interface Stager {
+
     StagedChange stage() throws Exception;
   }
 
@@ -1989,7 +2006,19 @@
   private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    pushFactory.create(by.newIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
+
+    // Use random file content to avoid that change kind is NO_CHANGE.
+    String randomContent = UUID.randomUUID().toString();
+    pushFactory
+        .create(
+            by.newIdent(),
+            sc.repo,
+            "New Patch Set",
+            PushOneCommit.FILE_NAME,
+            randomContent,
+            sc.changeId)
+        .to(ref)
+        .assertOkStatus();
   }
 
   @Test
@@ -2271,8 +2300,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.reviewer, admin)
         .cc(sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2295,8 +2325,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.reviewer, admin)
         .cc(sc.owner, sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2320,8 +2351,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.owner, sc.reviewer, admin)
         .cc(sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2345,8 +2377,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.owner, sc.reviewer, admin)
         .cc(sc.ccer, other)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6b34cca..576c7d0 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -300,8 +300,8 @@
         value(2, "Looks good to me, approved"),
         value(1, "Looks good to me, but someone else must approve"),
         value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
+        value(-1, "I would prefer this is not submitted as is"),
+        value(-2, "This shall not be submitted"));
 
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 731e0df..1468565 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -44,6 +45,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.project.SubmitRequirementEvaluationException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -273,6 +275,10 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED)
   public void submittabilityAndOverrideNotEvaluated_whenApplicabilityIsFalse() throws Exception {
     SubmitRequirement sr =
         createSubmitRequirement(
@@ -283,6 +289,24 @@
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
     assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.FAIL);
+    assertThat(result.submittabilityExpressionResult().get().status())
+        .isEqualTo(Status.NOT_EVALUATED);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("message:\"Fix bug\"");
+    assertThat(result.overrideExpressionResult().isPresent()).isFalse();
+  }
+
+  @Test
+  public void submittabilityAndOverrideAreEmpty_whenApplicabilityIsFalse() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+    assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.FAIL);
     assertThat(result.submittabilityExpressionResult().isPresent()).isFalse();
     assertThat(result.overrideExpressionResult().isPresent()).isFalse();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
index 925c855..b019354 100644
--- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -67,6 +67,29 @@
   }
 
   @Test
+  public void exactValuePredicate() throws Exception {
+    ApprovalContext approvalContextCodeReviewPlusOne = contextForCodeReviewLabel(1);
+    assertFalse(
+        queryBuilder.parse("is:\"-2\"").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(
+        queryBuilder.parse("is:\"-1\"").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(queryBuilder.parse("is:0").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertTrue(queryBuilder.parse("is:1").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(queryBuilder.parse("is:2").asMatchable().match(approvalContextCodeReviewPlusOne));
+  }
+
+  @Test
+  public void isPredicate_invalidValue() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("is:INVALID"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "INVALID is not a valid value for operator 'is'. Valid values: ANY, MAX, MIN"
+                + " or integer");
+  }
+
+  @Test
   public void changeKindPredicate_noCodeChange() throws Exception {
     String change = changeKindCreator.createChange(ChangeKind.NO_CODE_CHANGE, testRepo, admin);
     changeKindCreator.updateChange(change, ChangeKind.NO_CODE_CHANGE, testRepo, admin, project);
@@ -127,6 +150,17 @@
   }
 
   @Test
+  public void changeKindPredicate_invalidValue() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("changekind:INVALID"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "INVALID is not a valid value for operator 'changekind'. Valid values:"
+                + " MERGE_FIRST_PARENT_UPDATE, NO_CHANGE, NO_CODE_CHANGE, REWORK, TRIVIAL_REBASE");
+  }
+
+  @Test
   public void uploaderInPredicate() throws Exception {
     String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
 
@@ -241,7 +275,15 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains(
-            "'invalid' is not a supported argument for has. only 'unchanged-files' is supported");
+            "'invalid' is not a valid value for operator 'has'."
+                + " The only valid value is 'unchanged-files'.");
+  }
+
+  @Test
+  public void invalidQuery() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("INVALID"));
+    assertThat(thrown).hasMessageThat().contains("Unsupported query: INVALID");
   }
 
   private ApprovalContext contextForCodeReviewLabel(int value) throws Exception {
@@ -272,6 +314,7 @@
           approval,
           changeNotes.getPatchSets().get(newPsId),
           changeKind,
+          /* isMerge= */ false,
           rw,
           repo.getConfig());
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 3ba7829..4f93dd6 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
@@ -43,6 +44,8 @@
 public class RulesIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
+  @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private IndexOperations.Account accountIndexOperations;
 
   @Test
   public void testUnresolvedCommentsCountPredicate() throws Exception {
@@ -240,8 +243,8 @@
     ChangeData cd = result.getChange();
 
     Collection<SubmitRecord> records;
-    try (AutoCloseable ignored1 = disableChangeIndex();
-        AutoCloseable ignored2 = disableAccountIndex()) {
+    try (AutoCloseable ignored1 = changeIndexOperations.disableReadsAndWrites();
+        AutoCloseable ignored2 = accountIndexOperations.disableReadsAndWrites()) {
       SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
       records = ruleEvaluator.evaluate(cd);
     }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 18c4952..2a06900 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -36,6 +37,7 @@
 @UseSsh
 public abstract class AbstractIndexTests extends AbstractDaemonTest {
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
@@ -49,11 +51,10 @@
       String changeLegacyId = change.getChange().getId().toString();
       ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-      disableChangeIndexWrites();
-      amendChange(changeId, "second test", "test2.txt", "test2");
-
-      assertChangeQuery(change.getChange(), false);
-      enableChangeIndexWrites();
+      try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+        amendChange(changeId, "second test", "test2.txt", "test2");
+        assertChangeQuery(change.getChange(), false);
+      }
 
       changeIndexedCounter.clear();
       String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
@@ -77,11 +78,10 @@
       String changeId = change.getChangeId();
       ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-      disableChangeIndexWrites();
-      amendChange(changeId, "second test", "test2.txt", "test2");
-
-      assertChangeQuery(change.getChange(), false);
-      enableChangeIndexWrites();
+      try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+        amendChange(changeId, "second test", "test2.txt", "test2");
+        assertChangeQuery(change.getChange(), false);
+      }
 
       changeIndexedCounter.clear();
       String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index ac8a200..ab84e70 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -143,7 +143,7 @@
     // option causes the usage info to be written to stderr. Instead, we assert on the
     // content of the stderr, which will always start with "gerrit command" when the --help
     // option is used.
-    logger.atFine().log(cmd);
+    logger.atFine().log("%s", cmd);
     adminSshSession.exec(String.format("%s --help", cmd));
     String response = adminSshSession.getError();
     assertWithMessage(String.format("command %s failed: %s", cmd, response))
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index ce5cff7..84c3936 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogger;
@@ -92,6 +93,7 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "true")
   public void performanceLoggingForSshCall() throws Exception {
     TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
     try (Registration registration =
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index e0e1880..13a9e0c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -40,7 +40,6 @@
 public class StreamEventsIT extends AbstractDaemonTest {
   private static final Duration MAX_DURATION_FOR_RECEIVING_EVENTS = Duration.ofSeconds(2);
   private static final String TEST_REVIEW_COMMENT = "any comment";
-  private StringBuilder eventsOutput = new StringBuilder();
   private Reader streamEventsReader;
 
   @Before
@@ -56,7 +55,17 @@
   @Test
   public void commentOnChangeShowsUpInStreamEvents() throws Exception {
     reviewChange(new ReviewInput().message(TEST_REVIEW_COMMENT));
-    waitForEvent(() -> pollEventsContaining(TEST_REVIEW_COMMENT).size() == 1);
+    waitForEvent(() -> pollEventsContaining("comment-added", TEST_REVIEW_COMMENT).size() == 1);
+  }
+
+  @Test
+  public void batchRefsUpdatedShowSeparatelyInStreamEvents() throws Exception {
+    String refName = createChange().getChange().currentPatchSet().refName();
+    waitForEvent(
+        () ->
+            pollEventsContaining("ref-updated", refName.substring(0, refName.lastIndexOf('/')))
+                    .size()
+                == 2);
   }
 
   private void waitForEvent(Supplier<Boolean> waitCondition) throws InterruptedException {
@@ -68,16 +77,20 @@
     changeApi.current().review(reviewInput);
   }
 
-  private List<String> pollEventsContaining(String reviewComment) {
+  private List<String> pollEventsContaining(String eventType, String expectedContent) {
     try {
       char[] cbuf = new char[2048];
+      StringBuilder eventsOutput = new StringBuilder();
       while (streamEventsReader.ready()) {
         streamEventsReader.read(cbuf);
         eventsOutput.append(cbuf);
       }
       return StreamSupport.stream(
               Splitter.on('\n').trimResults().split(eventsOutput.toString()).spliterator(), false)
-          .filter(event -> event.contains(reviewComment))
+          .filter(
+              event ->
+                  event.contains(String.format("\"type\":\"%s\"", eventType))
+                      && event.contains(expectedContent))
           .collect(Collectors.toList());
     } catch (IOException e) {
       throw new IllegalStateException(e);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 0bd6554..6c629c9 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -610,6 +611,25 @@
   }
 
   @Test
+  public void createdChangeHasSpecifiedTopic() throws Exception {
+    Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.topic).isEqualTo("test-topic");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedApprovals() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().approvals(ImmutableMap.of("Code-Review", (short) 1)).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.labels).hasSize(1);
+    assertThat(change.labels.get("Code-Review").recommended._accountId)
+        .isEqualTo(change.owner._accountId);
+  }
+
+  @Test
   public void createdChangeHasSpecifiedCommitMessage() throws Exception {
     Change.Id changeId =
         changeOperations
diff --git a/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
new file mode 100644
index 0000000..a0fff22
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
@@ -0,0 +1,32 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ChangeSizeBucketTest {
+
+  @Test
+  public void getChangeSizeBucket() {
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(0)).isEqualTo("NoOp");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1)).isEqualTo("XS");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(10)).isEqualTo("S");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(50)).isEqualTo("M");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(250)).isEqualTo("L");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1000)).isEqualTo("XL");
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 25334fd..71695f3 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.Base64;
 import java.util.Optional;
 import javax.servlet.FilterChain;
@@ -73,8 +74,6 @@
 
   @Mock private AccountState accountState;
 
-  @Mock private Account account;
-
   @Mock private AccountManager accountManager;
 
   @Mock private AuthConfig authConfig;
@@ -106,12 +105,12 @@
     authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
     pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
 
+    Account account = Account.builder(Account.id(1000000), Instant.now()).build();
     authSuccessful =
         new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
     doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
     doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
     doReturn(account).when(accountState).account();
-    doReturn(true).when(account).isActive();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
 
     doReturn(new WebSessionManager.Key(AUTH_COOKIE_VALUE)).when(webSessionManager).createKey(any());
@@ -170,7 +169,6 @@
     requestBasicAuth(req);
     res.setStatus(HttpServletResponse.SC_OK);
 
-    doReturn(true).when(account).isActive();
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
     ProjectBasicAuthFilter basicAuthFilter =
@@ -267,7 +265,6 @@
     initMockedWebSession();
     requestBasicAuth(req);
 
-    doReturn(true).when(account).isActive();
     doThrow(new AccountException("Authentication error")).when(accountManager).authenticate(any());
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
@@ -291,7 +288,6 @@
   private void doFilterForRequestWhenAlreadySignedIn()
       throws IOException, ServletException, AccountException {
     initMockedWebSession();
-    doReturn(true).when(account).isActive();
     doReturn(true).when(webSession).isSignedIn();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
     requestBasicAuth(req);
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index 634231f..f65e823 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -105,7 +105,7 @@
     assertThat(output)
         .contains(
             "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22"
-                + String.join("\\x22,", expectedEnabled)
+                + String.join("\\x22,\\x22", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index dd594d6..36641fe 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -72,16 +72,6 @@
       this.fs = fs;
     }
 
-    private Servlet(
-        FileSystem fs,
-        Cache<Path, Resource> cache,
-        boolean refresh,
-        boolean cacheOnClient,
-        int cacheFileSizeLimitBytes) {
-      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
-      this.fs = fs;
-    }
-
     @Override
     protected Path getResourcePath(String pathInfo) {
       return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index f478803..0574746 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -19,6 +19,8 @@
 import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
 import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
 import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
+import static com.google.gerrit.index.query.QueryParser.NOT;
+import static com.google.gerrit.index.query.QueryParser.OR;
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
@@ -242,6 +244,36 @@
     assertThat(r).child(0).hasText("A backslash \\ in phrase");
   }
 
+  @Test
+  public void upperCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar AND file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar OR file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("NOT project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
+  public void lowerCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar and file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar or file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("not project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
   private static void assertParseFails(String query) {
     assertThrows(QueryParseException.class, () -> parse(query));
   }
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index acf9a50..d40f2a1 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -27,7 +29,11 @@
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -40,7 +46,10 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import java.io.File;
+import java.io.IOException;
 import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -62,9 +71,11 @@
   @Inject private GerritApi gApi;
   @Inject private AccountCreator accountCreator;
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
   @Inject private @TestSshServerAddress InetSocketAddress sshAddress;
   @Inject private @GerritServerConfig Config config;
   @Inject private AllProjectsName allProjectsName;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @BeforeClass
   public static void assertGitClientVersion() throws Exception {
@@ -86,15 +97,20 @@
       Project.NameKey project = Project.nameKey("foo");
       gApi.projects().create(project.get());
 
-      // Set up project permission
+      // Clear all permissions for anonymous users. Allow registered users to fetch/push.
+      AccountGroup.UUID admins = groupOperations.newGroup().addMember(admin.id()).create();
       projectOperations
-          .project(project)
+          .project(allProjectsName)
           .forUpdate()
-          .add(deny(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .removeAllAccessSections()
           .add(
               allow(Permission.READ)
                   .ref("refs/heads/master")
                   .group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.READ).ref("refs/*").group(admins))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(admins))
           .update();
 
       // Retrieve HTTP url
@@ -211,15 +227,17 @@
       Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
       gApi.projects().create(allRefsVisibleProject.get());
 
-      // Set up project permission to allow reading all refs
+      // Allow registered users to fetch/push. Allow anonymous users to read refs/heads/* which also
+      // allows reading changes.
       projectOperations
-          .project(allRefsVisibleProject)
+          .project(allProjectsName)
           .forUpdate()
+          .removeAllAccessSections()
           .add(allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
           .add(
-              allow(Permission.READ)
-                  .ref("refs/changes/*")
-                  .group(SystemGroupBackend.ANONYMOUS_USERS))
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
       // Create new change and retrieve refs for the created patch set
@@ -265,6 +283,140 @@
       Project.NameKey privateProject = Project.nameKey("private-project");
       gApi.projects().create(privateProject.get());
 
+      // Clear all permissions for anonymous users. Allow registered users to fetch/push.
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .removeAllAccessSections()
+          .add(
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(privateProject.get(), "master", "Test private change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput changeWeDidNotAskForIn =
+          new ChangeInput(privateProject.get(), "stable", "Test private change 2");
+      changeWeDidNotAskForIn.newBranch = true;
+      int changeWeDidNotAskForNumber = gApi.changes().create(changeWeDidNotAskForIn).info()._number;
+      Change.Id changeWeDidNotAskFor = Change.id(changeWeDidNotAskForNumber);
+      String changeWeDidNotAskForRef = RefNames.patchSetRef(PatchSet.id(changeWeDidNotAskFor, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_FETCH)
+                  .add(urlWithCredentials + "/" + privateProject.get())
+                  .add(visibleChangeNumberRef)
+                  .build(),
+              ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(visibleChangeNumberRef);
+      // refs-in-wants should not advertise changes we did not ask for
+      assertThat(outFetchRef).doesNotContain(changeWeDidNotAskForRef);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_doesNotNeedChangeIndex() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
+      // Disallow general read permissions for anonymous users
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(privateProject.get(), "master", "Test private change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput changeWeDidNotAskForIn =
+          new ChangeInput(privateProject.get(), "stable", "Test private change 2");
+      changeWeDidNotAskForIn.newBranch = true;
+      int changeWeDidNotAskForNumber = gApi.changes().create(changeWeDidNotAskForIn).info()._number;
+      Change.Id changeWeDidNotAskFor = Change.id(changeWeDidNotAskForNumber);
+      String changeWeDidNotAskForRef = RefNames.patchSetRef(PatchSet.id(changeWeDidNotAskFor, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef;
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        outFetchRef =
+            execute(
+                ImmutableList.<String>builder()
+                    .add(GIT_FETCH)
+                    .add(urlWithCredentials + "/" + privateProject.get())
+                    .add(visibleChangeNumberRef)
+                    .build(),
+                ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      }
+
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(visibleChangeNumberRef);
+      // refs-in-wants should not advertise changes we did not ask for
+      assertThat(outFetchRef).doesNotContain(changeWeDidNotAskForRef);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_doesNotNeedNoteDbWhenAskedForManyChanges()
+      throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
       // Disallow general read permissions for anonymous users
       projectOperations
           .project(allProjectsName)
@@ -286,28 +438,125 @@
                   .group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
-      // Create new change and retrieve refs for the created patch set
-      ChangeInput visibleChangeIn =
-          new ChangeInput(privateProject.get(), "master", "Test private change");
-      visibleChangeIn.newBranch = true;
-      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
-      Change.Id changeId = Change.id(visibleChangeNumber);
-      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+      List<String> changeRefs = new ArrayList<>();
+      for (int i = 0; i < 10; i++) {
+        // Create new change and retrieve refs for the created patch set
+        ChangeInput visibleChangeIn =
+            new ChangeInput(privateProject.get(), "master", "Test private change");
+        visibleChangeIn.newBranch = true;
+        int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+        Change.Id changeId = Change.id(visibleChangeNumber);
+        changeRefs.add(RefNames.patchSetRef(PatchSet.id(changeId, 1)));
+      }
 
       // Fetch a single ref using git wire protocol v2 over HTTP with authentication
       execute(GIT_INIT);
 
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        // Since we ask for many changes at once, the server will use the change index to speed up
+        // filtering. Having that disabled fails.
+        assertThrows(
+            IOException.class,
+            () ->
+                execute(
+                    ImmutableList.<String>builder()
+                        .add(GIT_FETCH)
+                        .add(urlWithCredentials + "/" + privateProject.get())
+                        .addAll(changeRefs)
+                        .build(),
+                    ImmutableMap.of("GIT_TRACE_PACKET", "1")));
+      }
+
+      // The same call succeeds if the change index is enabled.
       String outFetchRef =
           execute(
               ImmutableList.<String>builder()
                   .add(GIT_FETCH)
                   .add(urlWithCredentials + "/" + privateProject.get())
-                  .add(visibleChangeNumberRef)
+                  .addAll(changeRefs)
                   .build(),
               ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(changeRefs.get(0));
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_anonymousCantSeeInvisibleChange()
+      throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      String url = config.getString("gerrit", null, "canonicalweburl");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
+      // Disallow general read permissions for anonymous users except on master
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .removeAllAccessSections()
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn = new ChangeInput(privateProject.get(), "master", "Visible");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput invisibleChangeIn = new ChangeInput(privateProject.get(), "stable", "Invisible");
+      invisibleChangeIn.newBranch = true;
+      int invisibleChangeNumber = gApi.changes().create(invisibleChangeIn).info()._number;
+      Change.Id invisibleChange = Change.id(invisibleChangeNumber);
+      String invisibleChangeRef = RefNames.patchSetRef(PatchSet.id(invisibleChange, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef;
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        outFetchRef =
+            execute(
+                ImmutableList.<String>builder()
+                    .add(GIT_FETCH)
+                    .add(url + "/" + privateProject.get())
+                    .add(visibleChangeNumberRef)
+                    .build(),
+                ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      }
 
       assertThat(outFetchRef).contains("git< version 2");
       assertThat(outFetchRef).contains(visibleChangeNumberRef);
+
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        // Fetching invisible ref fails
+        assertThrows(
+            IOException.class,
+            () ->
+                execute(
+                    ImmutableList.<String>builder()
+                        .add(GIT_FETCH)
+                        .add(url + "/" + privateProject.get())
+                        .add(invisibleChangeRef)
+                        .build(),
+                    ImmutableMap.of("GIT_TRACE_PACKET", "1")));
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 1e3063e..5f062be 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
@@ -56,7 +57,8 @@
     backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
   }
 
   @Test
@@ -124,7 +126,8 @@
     backends.add("gerrit", backend);
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
 
     GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index b3b2f5a..c708e09 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -7,8 +7,11 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:junit",
         "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "@servlet-api//jar",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index 5d420d3..c04deb4 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -15,9 +15,13 @@
 package com.google.gerrit.server.cache;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import java.util.function.Supplier;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
 import org.junit.Test;
 
 public class PerThreadCacheTest {
@@ -43,7 +47,7 @@
 
   @Test
   public void endToEndCache() {
-    try (PerThreadCache ignored = PerThreadCache.create()) {
+    try (PerThreadCache ignored = PerThreadCache.create(null)) {
       PerThreadCache cache = PerThreadCache.get();
       PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
 
@@ -61,7 +65,7 @@
   @Test
   public void cleanUp() {
     PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
-    try (PerThreadCache ignored = PerThreadCache.create()) {
+    try (PerThreadCache ignored = PerThreadCache.create(null)) {
       PerThreadCache cache = PerThreadCache.get();
       String value1 = cache.get(key, () -> "value1");
       assertThat(value1).isEqualTo("value1");
@@ -69,7 +73,7 @@
 
     // Create a second cache and assert that it is not connected to the first one.
     // This ensures that the cleanup is actually working.
-    try (PerThreadCache ignored = PerThreadCache.create()) {
+    try (PerThreadCache ignored = PerThreadCache.create(null)) {
       PerThreadCache cache = PerThreadCache.get();
       String value1 = cache.get(key, () -> "value2");
       assertThat(value1).isEqualTo("value2");
@@ -78,16 +82,48 @@
 
   @Test
   public void doubleInstantiationFails() {
-    try (PerThreadCache ignored = PerThreadCache.create()) {
+    try (PerThreadCache ignored = PerThreadCache.create(null)) {
       IllegalStateException thrown =
-          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create(null));
       assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
     }
   }
 
   @Test
+  public void isAssociatedWithHttpReadonlyRequest() {
+    HttpServletRequest getRequest = new FakeHttpServletRequest();
+    try (PerThreadCache cache = PerThreadCache.create(getRequest)) {
+      assertThat(cache.getHttpRequest()).hasValue(getRequest);
+      assertThat(cache.hasReadonlyRequest()).isTrue();
+    }
+  }
+
+  @Test
+  public void isAssociatedWithHttpWriteRequest() {
+    HttpServletRequest putRequest =
+        new HttpServletRequestWrapper(new FakeHttpServletRequest()) {
+          @Override
+          public String getMethod() {
+            return "PUT";
+          }
+        };
+    try (PerThreadCache cache = PerThreadCache.create(putRequest)) {
+      assertThat(cache.getHttpRequest()).hasValue(putRequest);
+      assertThat(cache.hasReadonlyRequest()).isFalse();
+    }
+  }
+
+  @Test
+  public void isNotAssociatedWithHttpRequest() {
+    try (PerThreadCache cache = PerThreadCache.create(null)) {
+      assertThat(cache.getHttpRequest()).isEmpty();
+      assertThat(cache.hasReadonlyRequest()).isFalse();
+    }
+  }
+
+  @Test
   public void enforceMaxSize() {
-    try (PerThreadCache cache = PerThreadCache.create()) {
+    try (PerThreadCache cache = PerThreadCache.create(null)) {
       // Fill the cache
       for (int i = 0; i < 50; i++) {
         PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
index 8f5b215..3d0e508 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -1,3 +1,17 @@
+// 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.
+
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
index d7fdfe6..2eae1bf 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
index caf1fbb..0eb4103 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
index b39ba57..97d152b 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
index bff0c5d..38a2050 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// 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
+// 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
+// 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.
+// 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
index 4705c55..93f18d6 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -50,4 +51,12 @@
   public void roundTrip_withErrorMessage() throws Exception {
     assertThat(deserialize(serialize(r2))).isEqualTo(r2);
   }
+
+  @Test
+  public void deserializeUnknownStatus() throws Exception {
+    SubmitRequirementExpressionResultProto proto =
+        serialize(r1).toBuilder().setStatus("unknown").build();
+    assertThat(deserialize(proto).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
index 7b8db25..7e71a3e 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
@@ -191,6 +191,18 @@
   }
 
   @Test
+  public void submitRequirementExpressionResult_deserializeUnrecognizedStatus() throws Exception {
+    // If the status field has an unrecognized value while deserialization, we set the status field
+    // to ERROR.
+    String serial = srExpResultSerial.replace("FAIL", "UNKNOWN");
+    SubmitRequirementExpressionResult entity =
+        srExpResult.toBuilder().status(SubmitRequirementExpressionResult.Status.ERROR).build();
+    TypeAdapter<SubmitRequirementExpressionResult> adapter =
+        SubmitRequirementExpressionResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(serial)).isEqualTo(entity);
+  }
+
+  @Test
   public void submitRequirementResult_serialize() throws Exception {
     assertThat(SubmitRequirementResult.typeAdapter(gson).toJson(srReqResult))
         .isEqualTo(srReqResultSerial);
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index b048163..6cbbd26 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -21,7 +21,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.junit.Assert.assertEquals;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -50,7 +50,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
@@ -149,7 +148,7 @@
 
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 2);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -167,15 +166,15 @@
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 5);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 5);
     assertEquals(
-        Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
-        norm.normalize(notes, list(cr, v)));
+        Result.create(set(), set(copy(cr, 2), copy(v, 1)), set()),
+        norm.normalize(notes, set(cr, v)));
   }
 
   @Test
   public void emptyPermissionRangeKeepsResult() throws Exception {
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 1);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -191,7 +190,7 @@
 
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 0);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 0);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   private ProjectConfig loadAllProjects() throws Exception {
@@ -221,7 +220,7 @@
     return src.toBuilder().value(newValue).build();
   }
 
-  private static List<PatchSetApproval> list(PatchSetApproval... psas) {
-    return ImmutableList.copyOf(psas);
+  private static ImmutableSet<PatchSetApproval> set(PatchSetApproval... psas) {
+    return ImmutableSet.copyOf(psas);
   }
 }
diff --git a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
new file mode 100644
index 0000000..96919be
--- /dev/null
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import java.io.IOException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GitReferenceUpdatedTest {
+  private DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
+
+  @Mock GitReferenceUpdatedListener refUpdatedListener;
+  @Mock GitBatchRefUpdateListener batchRefUpdateListener;
+  @Mock EventUtil util;
+  @Mock AccountState updater;
+
+  @Before
+  public void setup() {
+    refUpdatedListeners = new DynamicSet<>();
+    refUpdatedListeners.add("gerrit", refUpdatedListener);
+    batchRefUpdateListeners = new DynamicSet<>();
+    batchRefUpdateListeners.add("gerrit", batchRefUpdateListener);
+  }
+
+  @Test
+  public void RefUpdateEventsAndRefsUpdateEventAreFired_BatchRefUpdate() {
+    BatchRefUpdate update = newBatchRefUpdate();
+    ReceiveCommand cmd1 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/1");
+    ReceiveCommand cmd2 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/meta");
+    cmd1.setResult(ReceiveCommand.Result.OK);
+    cmd2.setResult(ReceiveCommand.Result.OK);
+    update.addCommand(cmd1);
+    update.addCommand(cmd2);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(2)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  @Test
+  public void RefUpdateEventAndRefsUpdateEventAreFired_RefUpdate() throws Exception {
+    String ref = "refs/heads/master";
+    RefUpdate update = newRefUpdate(ref);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(1)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  private static BatchRefUpdate newBatchRefUpdate() {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newBatchUpdate();
+    }
+  }
+
+  private static RefUpdate newRefUpdate(String ref) throws IOException {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newUpdate(ref, false);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index fa5c47f..42a80c3 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -248,4 +248,30 @@
     assertThat(edit).internalEdits().element(0).isReplace(6, 12, 6, 9);
     assertThat(edit).internalEdits().element(1).isReplace(18, 10, 15, 7);
   }
+
+  @Test
+  public void overlappingChangesInMiddleOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 5), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 4, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
+
+  @Test
+  public void overlappingChangesInBeginningOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 1), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 0, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index dbe255c..a8f9ff5 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -19,8 +19,6 @@
 import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
-import static org.hamcrest.CoreMatchers.instanceOf;
-import static org.hamcrest.MatcherAssert.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -115,8 +113,9 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
@@ -137,8 +136,9 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
     }
   }
@@ -213,8 +213,9 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
@@ -521,8 +522,9 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
@@ -585,8 +587,9 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index fc56a3c..07abae9 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -28,11 +28,10 @@
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static final Schema<ChangeData> V1 = new Schema<>(1, false, ImmutableList.of(ChangeField.STATUS));
+  static final Schema<ChangeData> V1 = new Schema<>(1, ImmutableList.of(ChangeField.STATUS));
 
   static final Schema<ChangeData> V2 =
-      new Schema<>(
-          2, false, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
+      new Schema<>(2, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 8d019f3..1f0da16 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -56,6 +56,9 @@
           }
         };
     performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+
+    // Enable performance logging
+    config.setBoolean("tracing", null, "performanceLogging", true);
   }
 
   @After
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index e60d6b4..b1cd8fb 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -56,6 +56,9 @@
 
     testPerformanceLogger = new TestPerformanceLogger();
     performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+
+    // Enable performance logging
+    config.setBoolean("tracing", null, "performanceLogging", true);
   }
 
   @After
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
index 78cefdf..d7a6282 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -22,7 +22,7 @@
 public class CommentSenderTest {
   private static class TestSender extends CommentSender {
     TestSender() {
-      super(null, null, null, null, null);
+      super(null, null, null, null, null, null, null);
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
index bb443f8..3ce60b8 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 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.mail.send;
 
 import static com.google.common.truth.Truth.assertThat;
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
new file mode 100644
index 0000000..43153ae
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gson.Gson;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ChangeNoteJsonTest {
+  private final Gson gson = new ChangeNoteJson().getGson();
+
+  @Test
+  public void shouldSerializeAndDeserializeEmptyOptional() {
+    // given
+    Optional<?> empty = Optional.empty();
+
+    // when
+    String json = gson.toJson(empty);
+
+    // then
+    assertThat(json).isEqualTo("{}");
+
+    // and when
+    Optional<?> result = gson.fromJson(json, Optional.class);
+
+    // and then
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNonEmptyOptional() {
+    // given
+    String value = "foo";
+    Optional<String> nonEmpty = Optional.of(value);
+
+    // when
+    String json = gson.toJson(nonEmpty);
+
+    // then
+    assertThat(json).isEqualTo("{\n  \"value\": \"" + value + "\"\n}");
+
+    // and when
+    Optional<String> result = gson.fromJson(json, new TypeLiteral<Optional<String>>() {}.getType());
+
+    // and then
+    assertThat(result).isPresent();
+    assertThat(result.get()).isEqualTo(value);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 09c8059..8def660 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -163,7 +163,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     assertThat(approvals.entries().asList().get(0).getValue().tag()).hasValue(tag2);
   }
@@ -209,7 +209,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
     assertThat(approval.tag()).hasValue(integrationTag);
@@ -235,8 +235,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -269,7 +269,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
+    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals().all();
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
@@ -298,7 +298,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
     assertParsedUuid(psa);
@@ -308,7 +308,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
     assertParsedUuid(psa);
@@ -326,8 +326,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -354,7 +354,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
@@ -365,7 +365,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals())
+    assertThat(notes.getApprovals().all())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 psa.patchSetId(),
@@ -386,7 +386,7 @@
 
       ChangeNotes notes = newNotes(c);
       PatchSetApproval psa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(psa.value()).isEqualTo((short) value);
@@ -403,7 +403,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
@@ -415,7 +415,7 @@
 
     notes = newNotes(c);
     PatchSetApproval emptyPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(emptyPsa.key()).isEqualTo(psa.key());
     assertThat(emptyPsa.value()).isEqualTo((short) 0);
     assertThat(emptyPsa.label()).isEqualTo(psa.label());
@@ -431,7 +431,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
@@ -443,7 +443,7 @@
 
     notes = newNotes(c);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(removedPsa.key()).isEqualTo(psa.key());
     assertThat(removedPsa.value()).isEqualTo((short) 0);
     assertThat(removedPsa.label()).isEqualTo(psa.label());
@@ -458,9 +458,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
@@ -474,9 +474,9 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(removedPsa.key()).isEqualTo(originalPsa.key());
     assertThat(removedPsa.value()).isEqualTo(0);
     // Add approval with the same author, label, value to the current patch set
@@ -485,9 +485,9 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
     PatchSetApproval reAddedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(reAddedPsa.key()).isEqualTo(originalPsa.key());
     assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
@@ -504,9 +504,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(originalPsa.accountId().get()).isEqualTo(1);
@@ -521,11 +521,11 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(2);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
     PatchSetApproval postUpdateOriginalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(originalPsa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(originalPsa.patchSetId()));
     PatchSetApproval reAddedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     // Same patch set approval for the original patch set is returned after the vote was re-issued
     // on the next patch set
@@ -549,8 +549,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
             patchSetApprovals.stream()
@@ -573,8 +574,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
             patchSetApprovals.stream()
@@ -606,10 +608,10 @@
 
     ChangeNotes notes1 = newNotes(c1);
     PatchSetApproval psa1 =
-        Iterables.getOnlyElement(notes1.getApprovals().get(c1.currentPatchSetId()));
+        Iterables.getOnlyElement(notes1.getApprovals().all().get(c1.currentPatchSetId()));
     ChangeNotes notes2 = newNotes(c2);
     PatchSetApproval psa2 =
-        Iterables.getOnlyElement(notes2.getApprovals().get(c2.currentPatchSetId()));
+        Iterables.getOnlyElement(notes2.getApprovals().all().get(c2.currentPatchSetId()));
     assertThat(psa1.label()).isEqualTo(psa2.label());
     assertThat(psa1.accountId()).isEqualTo(psa2.accountId());
     assertThat(psa1.value()).isEqualTo(psa2.value());
@@ -627,7 +629,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
@@ -639,7 +641,7 @@
 
     notes = newNotes(c);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(removedPsa.key()).isEqualTo(psa.key());
     assertThat(removedPsa.value()).isEqualTo((short) 0);
     assertThat(removedPsa.label()).isEqualTo(psa.label());
@@ -651,7 +653,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 2);
@@ -668,7 +670,7 @@
 
     ChangeNotes notes = newNotes(c);
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovals().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -710,7 +712,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
@@ -753,7 +755,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(3);
     assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
@@ -783,7 +785,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(originalPsa.value()).isEqualTo(2);
@@ -797,15 +799,15 @@
     addCopiedApproval(c, changeOwner, originalPsa);
 
     notes = newNotes(c);
-    assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
     PatchSetApproval copiedApproval =
         Iterables.getOnlyElement(
-            notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+            notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                 .filter(a -> a.copied())
                 .collect(toImmutableList()));
     PatchSetApproval nonCopiedApproval =
         Iterables.getOnlyElement(
-            notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+            notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                 .filter(a -> !a.copied())
                 .collect(toImmutableList()));
 
@@ -829,7 +831,7 @@
       update.commit();
       ChangeNotes notes = newNotes(c);
       PatchSetApproval originalPsa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(originalPsa.value()).isEqualTo(2);
@@ -843,15 +845,15 @@
       addCopiedApproval(c, changeOwner, originalPsa);
 
       notes = newNotes(c);
-      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
       PatchSetApproval copiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                   .filter(a -> a.copied())
                   .collect(toImmutableList()));
       PatchSetApproval nonCopiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                   .filter(a -> !a.copied())
                   .collect(toImmutableList()));
 
@@ -876,7 +878,7 @@
 
       ChangeNotes notes = newNotes(c);
       PatchSetApproval originalPsa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(originalPsa.value()).isEqualTo(2);
@@ -889,15 +891,15 @@
       addCopiedApproval(c, changeOwner, originalPsa);
 
       notes = newNotes(c);
-      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
       PatchSetApproval copiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                   .filter(a -> a.copied())
                   .collect(toImmutableList()));
       PatchSetApproval nonCopiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                   .filter(a -> !a.copied())
                   .collect(toImmutableList()));
 
@@ -929,18 +931,16 @@
     update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
-    // Only the non copied approval is reachable by getApprovals.
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().onlyNonCopied().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) -1);
     assertThat(approval.copied()).isFalse();
 
-    // Get approvals with copied gets all of the approvals (including copied).
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -983,7 +983,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     // The vote got removed since the latest patch-set only has one vote and it's "0". The copied
@@ -1014,7 +1014,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) 1);
@@ -1077,7 +1077,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovalsWithCopied().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
@@ -1163,7 +1163,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
@@ -1173,7 +1173,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psas = notes.getApprovals().get(c.currentPatchSetId());
+    psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(1);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
   }
@@ -1895,7 +1895,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getApprovals()).isNotEmpty();
+    assertThat(notes.getApprovals().all()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
     assertThat(notes.getHumanComments()).isNotEmpty();
 
@@ -1911,7 +1911,7 @@
 
     notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals().all()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
     assertThat(notes.getHumanComments()).isEmpty();
   }
@@ -2024,7 +2024,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
@@ -2130,11 +2130,11 @@
     assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
 
     PatchSetApproval approval1 =
-        newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
+        newNotes(c1).getApprovals().all().get(c1.currentPatchSetId()).iterator().next();
     assertThat(approval1.label()).isEqualTo(LabelId.VERIFIED);
 
     PatchSetApproval approval2 =
-        newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
+        newNotes(c2).getApprovals().all().get(c2.currentPatchSetId()).iterator().next();
     assertThat(approval2.label()).isEqualTo(LabelId.CODE_REVIEW);
   }
 
@@ -3528,7 +3528,7 @@
     ChangeNotes notes = newNotes(c);
     int numMessages = notes.getChangeMessages().size();
     int numPatchSets = notes.getPatchSets().size();
-    int numApprovals = notes.getApprovals().size();
+    int numApprovals = notes.getApprovals().all().size();
     int numComments = notes.getHumanComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3556,7 +3556,7 @@
     notes = newNotes(c);
     assertThat(notes.getChangeMessages()).hasSize(numMessages);
     assertThat(notes.getPatchSets()).hasSize(numPatchSets);
-    assertThat(notes.getApprovals()).hasSize(numApprovals);
+    assertThat(notes.getApprovals().all()).hasSize(numApprovals);
     assertThat(notes.getHumanComments()).hasSize(numComments);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 3b18183..051ea2d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -773,9 +773,9 @@
                 .build());
     ChangeNotes notesAfterRewrite = newNotes(c);
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -895,14 +895,14 @@
             "Removed Custom-Label-1 by Other Account <other@account.com>",
             "Removed Verified+2 by Change Owner <change@owner.com>");
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
     assertThat(changeMessages(notesAfterRewrite))
         .containsExactly(
             "Removed Code-Review+2 by <GERRIT_ACCOUNT_2>",
             "Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>",
             "Removed Verified+2 by <GERRIT_ACCOUNT_1>");
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index fa04cf8..788703c 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -93,7 +93,7 @@
   }
 
   @Test
-  public void diffAgainstAutoMergePersistsAutoMergeInRepo() throws Exception {
+  public void diffAgainstAutoMergeDoesNotPersistAutoMergeInRepo() throws Exception {
     ObjectId parent1 =
         createCommit(repo, null, ImmutableList.of(new FileEntity("file_1.txt", "file 1 content")));
     ObjectId parent2 =
@@ -117,8 +117,7 @@
             testProjectName, merge, /* parentNum=*/ 0, DiffOptions.DEFAULTS);
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST", "file_3.txt");
 
-    // Requesting diff against auto-merge had the side effect of updating the auto-merge ref
-    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNotNull();
+    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index e98b6bf..2b7d7af 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -1057,6 +1057,24 @@
             });
   }
 
+  @Test
+  public void readCopyValues_emptyValueIsIgnored() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  copyValue = 1\n"
+                    + "  copyValue = 2\n"
+                    + "  copyValue = \n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    assertThat(labels.entrySet().iterator().next().getValue().getCopyValues())
+        .containsExactly((short) 1, (short) 2);
+  }
+
   private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
     Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
     Files.createDirectories(dir);
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 22207cc..b05f3c7 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -46,7 +46,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_WITH_BLOCK)
             .build();
 
@@ -56,7 +56,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_NO_BLOCK)
             .build();
 
@@ -66,7 +66,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.ANY_WITH_BLOCK)
             .build();
 
@@ -76,7 +76,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_WITH_BLOCK)
             .setIgnoreSelfApproval(true)
             .build();
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 16f7199..9a2499a 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -269,19 +268,7 @@
     AccountInfo user2 = newAccount("user");
     requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
 
-    if (getSchemaVersion() < 5) {
-      assertMissingField(AccountField.PREFERRED_EMAIL);
-      assertFailingQuery("email:foo", "'email' operator is not supported by account index version");
-      return;
-    }
-
-    // This at least needs the PREFERRED_EMAIL field which is available from schema version 5.
-    if (getSchemaVersion() >= 5) {
-      assertQuery(preferredEmail, user1);
-    } else {
-      assertQuery(preferredEmail);
-    }
-
+    assertQuery(preferredEmail, user1);
     assertQuery(secondaryEmail);
 
     assertQuery("email:" + preferredEmail, user1);
@@ -369,14 +356,6 @@
     assertQuery("self", user3);
     assertQuery("me", user3);
 
-    if (getSchemaVersion() < 8) {
-      assertMissingField(AccountField.NAME_PART_NO_SECONDARY_EMAIL);
-
-      // prefix queries only work if the NAME_PART_NO_SECONDARY_EMAIL field is available
-      assertQuery("john");
-      return;
-    }
-
     assertQuery("John", user1);
     assertQuery("john", user1);
     assertQuery("Doe", user1);
@@ -649,18 +628,13 @@
                     IndexConfig.createDefault(), 0, 1, schema.getStoredFields().keySet()));
 
     assertThat(rawFields).isPresent();
-    if (schema.useLegacyNumericFields()) {
+    if (schema.hasField(AccountField.ID)) {
       assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
     } else {
       assertThat(Integer.valueOf(rawFields.get().getValue(AccountField.ID_STR)))
           .isEqualTo(userInfo._accountId);
     }
 
-    // The field EXTERNAL_ID_STATE is only supported from schema version 6.
-    if (getSchemaVersion() < 6) {
-      return;
-    }
-
     List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
     List<ByteArrayWrapper> blobs = new ArrayList<>();
     for (AccountExternalIdInfo info : externalIdInfos) {
@@ -876,12 +850,6 @@
     return accounts.stream().map(a -> a._accountId).collect(toList());
   }
 
-  protected void assertMissingField(FieldDef<AccountState, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
   protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
     try {
       assertQuery(query);
@@ -891,14 +859,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
-  protected Schema<AccountState> getSchema() {
-    return indexes.getSearchIndex().getSchema();
-  }
-
   /** Boiler plate code to check two byte arrays for equality */
   private static class ByteArrayWrapper {
     private byte[] arr;
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index 4ae2039..c255f5d 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -53,11 +53,9 @@
     visibility = ["//visibility:public"],
     deps = [
         ":abstract_query_tests",
-        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index ab01bf3..baa4802 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -91,7 +91,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -176,7 +175,7 @@
   @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected Provider<ChangeQueryBuilder> queryBuilderProvider;
+  @Inject protected ChangeQueryBuilder queryBuilder;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
@@ -354,6 +353,18 @@
   }
 
   @Test
+  public void byStatusOr() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert(repo, ins2);
+
+    assertQuery("status:new OR status:merged", change2, change1);
+    assertQuery("status:new or status:merged", change2, change1);
+  }
+
+  @Test
   public void byStatusOpen() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
@@ -638,7 +649,6 @@
 
   @Test
   public void byAuthorExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
   }
 
@@ -649,7 +659,6 @@
 
   @Test
   public void byCommitterExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
     byAuthorOrCommitterExact("committer:");
   }
 
@@ -1595,11 +1604,9 @@
     assertQuery("ext:.jAvA", change4);
     assertQuery("ext:cc", change3, change2, change1);
 
-    if (getSchemaVersion() >= 56) {
-      // matching changes with files that have no extension is possible
-      assertQuery("ext:\"\"", change5, change4);
-      assertFailingQuery("ext:");
-    }
+    // matching changes with files that have no extension is possible
+    assertQuery("ext:\"\"", change5, change4);
+    assertFailingQuery("ext:");
   }
 
   @Test
@@ -1963,21 +1970,6 @@
   }
 
   @Test
-  public void mergedOperatorSupportedByIndexVersion() throws Exception {
-    if (getSchemaVersion() < 61) {
-      assertMissingField(ChangeField.MERGED_ON);
-      assertFailingQuery(
-          "mergedbefore:2009-10-01",
-          "'mergedbefore' operator is not supported by change index version");
-      assertFailingQuery(
-          "mergedafter:2009-10-01",
-          "'mergedafter' operator is not supported by change index version");
-    } else {
-      assertThat(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
-    }
-  }
-
-  @Test
   public void byMergedBefore() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
@@ -1996,12 +1988,13 @@
     TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
     submit(change2);
     TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
-    // Put another approval on the change, just to update it.
+    // Put another approval on the change, just to update it. This does not record an update in
+    // NoteDb since this is a no/op.
     approve(change1);
     approve(change3);
 
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
@@ -2020,7 +2013,7 @@
     assertQuery("mergedbefore:2009-10-03", change3);
     // Changes are sorted by lastUpdatedOn first, then by mergedOn.
     // Even though Change2 was merged after Change3, Change3 is returned first.
-    assertQuery("mergedbefore:2009-10-04", change3, change2);
+    assertQuery("mergedbefore:2009-10-04", change2, change3);
 
     // Same test as above, but using filter code path.
     assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-01"));
@@ -2033,7 +2026,7 @@
         makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\""), change3);
     assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00\""), change3);
     assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-03"), change3);
-    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change2, change3);
   }
 
   @Test
@@ -2058,13 +2051,14 @@
     submit(change2);
 
     TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
-    // Put another approval on the change, just to update it.
+    // Put another approval on the change, just to update it. This does not record an update
+    // in NoteDb since this is a no/op.
     approve(change1);
     approve(change3);
 
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
@@ -2072,36 +2066,36 @@
     // 1. Change1 was not submitted and should be never returned.
     // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
     // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
-    assertQuery("mergedafter:2009-10-01", change3, change2);
+    assertQuery("mergedafter:2009-10-01", change2, change3);
     // Changes are sorted by lastUpdatedOn first, then by mergedOn.
-    // Even though Change2 was merged after Change3, Change3 is returned first.
-    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change3, change2);
-    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change3, change2);
+    // Change 2 (which was updated last) is returned before change 3.
+    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change2, change3);
+    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change2, change3);
     assertQuery("mergedafter:\"2009-10-01 23:02:00 -0400\"", change2);
     assertQuery("mergedafter:\"2009-10-02 03:02:00 -0000\"", change2);
     // Changes included on the date submitted.
-    assertQuery("mergedafter:2009-10-02", change3, change2);
+    assertQuery("mergedafter:2009-10-02", change2, change3);
     assertQuery("mergedafter:2009-10-03", change2);
 
     // Same test as above, but using filter code path.
 
-    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change2, change3);
     // Changes are sorted by lastUpdatedOn first, then by mergedOn.
     // Even though Change2 was merged after Change3, Change3 is returned first.
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 22:59:00 -0400\""),
-        change3,
-        change2);
+        change2,
+        change3);
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 02:59:00 -0000\""),
-        change3,
-        change2);
+        change2,
+        change3);
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 23:02:00 -0400\""), change2);
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 03:02:00 -0000\""), change2);
     // Changes included on the date submitted.
-    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-02"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-02"), change2, change3);
     assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-03"), change2);
   }
 
@@ -2124,14 +2118,15 @@
     submit(change2);
     submit(change3);
     TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
-    // Approve post submit just to update lastUpdatedOn
+    // Approve post submit just to update lastUpdatedOn. This does not record an update in NoteDb
+    // since this is a No/op.
     approve(change3);
     approve(change2);
     submit(change1);
 
     // All Changes were last updated at the same time.
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 2 * thirtyHoursInMs);
-    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 2 * thirtyHoursInMs);
 
     // Changes are sorted by lastUpdatedOn first, then by mergedOn, then by Id in reverse order.
@@ -2458,10 +2453,6 @@
 
   @Test
   public void bySubmitRuleResult() throws Exception {
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
@@ -2482,13 +2473,6 @@
 
   @Test
   public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
-    // Some submit rules could be removed from the gerrit.config but there can be records for
-    // merged changes in NoteDb for these rules. We allow querying for non-existent rules to handle
-    // this case.
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
@@ -4097,7 +4081,6 @@
 
     assertQuery(ChangeIndexPredicate.none());
 
-    ChangeQueryBuilder queryBuilder = queryBuilderProvider.get();
     for (Predicate<ChangeData> matchingOneChange :
         ImmutableList.of(
             // One index query, one post-filtering query.
@@ -4467,12 +4450,6 @@
     }
   }
 
-  protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
   protected void assertFailingQuery(String query) throws Exception {
     assertFailingQuery(query, null);
   }
@@ -4489,10 +4466,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
   protected Schema<ChangeData> getSchema() {
     return indexes.getSearchIndex().getSchema();
   }
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 08456d1..3fde339 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -26,7 +26,6 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 4b74325..0cc132d 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -54,6 +54,5 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index a65306c..f476ae6 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -51,10 +51,8 @@
     deps = [
         ":abstract_query_tests",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 9cba362..1304c53 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -77,7 +78,11 @@
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
     assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
-    assertThat(codeReview.isCopyMinScore()).isTrue();
+    assertThat(codeReview.getCopyCondition())
+        .hasValue(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertValueRange(codeReview, -2, -1, 0, 1, 2);
   }
 
diff --git a/lib/BUILD b/lib/BUILD
index 7b48e6a..7aa9a45 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -488,17 +488,17 @@
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
     exports = [
-        ":hamcrest-core",
+        ":hamcrest",
         "@junit//jar",
     ],
-    runtime_deps = [":hamcrest-core"],
+    runtime_deps = [":hamcrest"],
 )
 
 java_library(
-    name = "hamcrest-core",
+    name = "hamcrest",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
-    exports = ["@hamcrest-core//jar"],
+    exports = ["@hamcrest//jar"],
 )
 
 java_library(
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 18b9b91..e01d91b 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -10,6 +10,23 @@
 )
 
 java_plugin(
+    name = "auto-factory-plugin",
+    generates_api = 1,
+    processor_class = "com.google.auto.factory.processor.AutoFactoryProcessor",
+    visibility = ["//visibility:private"],
+    deps = [
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+        "@guava//jar",
+        "@javapoet//jar",
+        "@javax_inject//jar",
+    ],
+)
+
+java_plugin(
     name = "auto-value-plugin",
     processor_class = "com.google.auto.value.processor.AutoValueProcessor",
     deps = [
@@ -43,6 +60,16 @@
 )
 
 java_library(
+    name = "auto-factory",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-factory-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-factory//jar"],
+)
+
+java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
     exported_plugins = [
diff --git a/lib/log/BUILD b/lib/log/BUILD
index 6a85bd1..21c4d47 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -18,6 +18,14 @@
 )
 
 java_library(
+    name = "impl-log4j",
+    data = ["//lib:LICENSE-slf4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@impl-log4j//jar"],
+    runtime_deps = [":log4j"],
+)
+
+java_library(
     name = "jcl-over-slf4j",
     data = ["//lib:LICENSE-slf4j"],
     visibility = ["//visibility:public"],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 9e9d750..51c50bf 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -25,9 +25,14 @@
 guice-assistedinject
 guice-library
 guice-servlet
+hamcrest
+impl-log4j
 j2objc
+jcl-over-slf4j
 jimfs
 jruby
+log-api
+log-ext
 log4j
 lucene-analyzers-common
 lucene-core
diff --git a/modules/jgit b/modules/jgit
index 56f45e3..e982de3 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 56f45e36dc2ccb0803ad810098bc4a0ac8f4a675
+Subproject commit e982de3fcb9f3a2e5ec6ceaae44cbb344962ea01
diff --git a/plugins/gitiles b/plugins/gitiles
index b62b109..648b1df 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit b62b1098cfc566f5edb9e9a3fed8be20210675f5
+Subproject commit 648b1df92b887ed5011e3a2fbf18fd2b9e8622b3
diff --git a/plugins/replication b/plugins/replication
index 220ce68..fd3b732 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 220ce6829d4b8382374b08045dc4a1459db8f001
+Subproject commit fd3b732959f964763117be5fdff78ee40ed211fa
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index cd88f52..732a82c 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -45,7 +45,9 @@
 bazel clean --expunge
 ```
 
-The minimum nodejs version supported is 8.x+
+The minimum nodejs version supported is 8.x+, the maximum is currently 16.x due
+to [an issue](https://github.com/karma-runner/karma/issues/3730) in our test
+runner, karma.
 
 ```sh
 # Debian experimental
@@ -53,7 +55,7 @@
 sudo apt-get install npm
 
 # OS X with Homebrew
-brew install node
+brew install node@16
 brew install npm
 ```
 
@@ -94,7 +96,7 @@
 
 ## Setup typescript support in the IDE
 
-Modern IDE should automatically handle typescript settings from the 
+Modern IDE should automatically handle typescript settings from the
 `pollygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
 `.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
 to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
@@ -238,34 +240,6 @@
 the "Before launch" section for IntelliJ. This is a temporary problem until
 typescript migration is complete.
 
-## Running Templates Test
-The templates test validates polymer templates. The test convert polymer
-templates into a plain typescript code and then run TS compiler. The test fails
-if TS compiler reports errors; in this case you will see TS errors in
-the log/output. Gerrit-CI automatically runs templates test.
-
-**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
-are excluded from code generation and checking. If you don't know how to fix
-a problem, you can add a problematic template in the list.
-
-* To run test locally, use npm command:
-``` sh
-npm run polytest
-```
-
-* Often, the output from the previous command is not clear (cryptic TS errors).
-In this case, run the command
-```sh
-npm run polytest:dev
-```
-This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
-generated files into it. For each polygerrit .ts file there is a generated file
-in the `tmpl_out` directory. If an original file doesn't contain a polymer
-template, the generated file is empty.
-
-You can open a problematic file in IDE and fix the problem. Ensure, that IDE
-uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
-
 ### Generated file overview
 
 A generated file starts with imports followed by a static content with
@@ -286,7 +260,7 @@
 additional functions are added. For example, `<element x=[[y.a]]>` converts into
 `el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
 then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
-if `a` is defined only in one type of a union). 
+if `a` is defined only in one type of a union).
 
 ## Style guide
 
@@ -352,7 +326,7 @@
    ```
    // Before:
    import ... from 'x/y/z.js`
- 
+
    // After
    import .. from 'x/y/z'
    ```
@@ -421,16 +395,16 @@
 ...
 // The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
 assert.equal(element._robotCommentThreads.length, 2);
-  
+
 // Fix with non-null assertion operator:
 const rows = element
   .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
   .querySelectorAll('tbody tr');
 
-assert.equal(element._robotCommentThreads!.length, 2); 
+assert.equal(element._robotCommentThreads!.length, 2);
 
 // Fix with nullish coalescing operator:
- assert.equal(element._robotCommentThreads?.length, 2); 
+ assert.equal(element._robotCommentThreads?.length, 2);
 ```
 Usually the fix with `!` is preferable, because it gives more clear error
 when an intermediate property is `null/undefined`. If the _robotComments is
@@ -527,7 +501,7 @@
 
 * If a test imports a library from `polygerrit_ui/node_modules` - update
 `paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
- 
+
 ## Contributing
 
 Our users report bugs / feature requests related to the UI through [Monorail Issues - PolyGerrit](https://bugs.chromium.org/p/gerrit/issues/list?q=component%3APolyGerrit).
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index a4dcb64..82873a7 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -88,7 +88,11 @@
     // https://eslint.org/docs/rules/no-console
     'no-console': [
       'error',
-      {allow: ['warn', 'error', 'info', 'assert', 'group', 'groupEnd']},
+      {
+        allow: [
+          'warn', 'error', 'info', 'debug', 'assert', 'group', 'groupEnd',
+        ],
+      },
     ],
     // https://eslint.org/docs/rules/no-multiple-empty-lines
     'no-multiple-empty-lines': ['error', {max: 1}],
@@ -174,7 +178,9 @@
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
     'jsdoc/check-syntax': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
-    'jsdoc/check-tag-names': 0,
+    'jsdoc/check-tag-names': ['error', {
+      definedTags: ['attr', 'lit', 'mixinFunction', 'mixinClass', 'polymer'],
+    }],
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
     'jsdoc/check-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
@@ -307,6 +313,13 @@
         '@typescript-eslint/ban-ts-comment': 'off',
         // The following rules is required to match internal google rules
         '@typescript-eslint/restrict-plus-operands': 'error',
+        '@typescript-eslint/no-unnecessary-type-assertion': 'error',
+        'require-await': 'off',
+        '@typescript-eslint/require-await': 'error',
+        '@typescript-eslint/no-confusing-void-expression': [
+          'error',
+          {ignoreArrowShorthand: true},
+        ],
         '@typescript-eslint/no-unused-vars': [
           'error',
           {argsIgnorePattern: '^_'},
@@ -315,7 +328,7 @@
         'node/no-unsupported-features/node-builtins': 'off',
         // Disable no-invalid-this for ts files, because it incorrectly reports
         // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
-        // At the same time, we are using typescript in a strict mode and
+        // At the same tigit llme, we are using typescript in a strict mode and
         // it catches almost all errors related to invalid usage of this.
         'no-invalid-this': 'off',
 
@@ -337,6 +350,7 @@
       ],
       rules: {
         '@typescript-eslint/no-explicit-any': 'off',
+        '@typescript-eslint/require-await': 'off',
       },
     },
     {
@@ -425,15 +439,17 @@
         'lit/attribute-value-entities': 'error',
         'lit/binding-positions': 'error',
         'lit/no-duplicate-template-bindings': 'error',
+        'lit/no-invalid-escape-sequences': 'error',
         'lit/no-invalid-html': 'error',
         'lit/no-legacy-template-syntax': 'error',
-        'lit/no-property-change-update': 'error',
-        'lit/no-invalid-escape-sequences': 'error',
         'lit/no-legacy-imports': 'error',
         'lit/no-private-properties': 'error',
+        'lit/no-property-change-update': 'error',
+        'lit/no-template-bind': 'error',
         'lit/no-useless-template-literals': 'error',
         'lit/no-value-attribute': 'error',
         'lit/prefer-static-styles': 'error',
+        'lit/quoted-expressions': ['error', 'never'],
       },
     },
   ],
@@ -451,5 +467,11 @@
       node: {},
       [path.resolve(__dirname, './.eslint-ts-resolver.js')]: {},
     },
+    'jsdoc': {
+      tagNamePreference: {
+        returns: 'return',
+        file: 'fileoverview',
+      },
+    },
   },
 };
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 5b81269..084befa 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -16,7 +16,6 @@
     "gr-diff",
     "mixins",
     "models",
-    "samples",
     "scripts",
     "services",
     "styles",
@@ -247,6 +246,7 @@
     ],
     ignore = ".eslintignore",
     plugins = [
+        "@npm//@typescript-eslint/eslint-plugin",
         "@npm//eslint-config-google",
         "@npm//eslint-plugin-html",
         "@npm//eslint-plugin-import",
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index aa50388..4322c64 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -38,10 +38,16 @@
  * If the weblinks-only parameter is specified, only the web_links field is set.
  */
 export declare interface DiffInfo {
-  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
-  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side A as a DiffFileMetaInfo entity.
+   * Not set when change_type is ADDED.
+   */
+  meta_a?: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side B as a DiffFileMetaInfo entity.
+   * Not set when change_type is DELETED.
+   */
+  meta_b?: DiffFileMetaInfo;
   /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
   change_type: ChangeType;
   /** Intraline status (OK, ERROR, TIMEOUT). */
@@ -167,7 +173,7 @@
    * Indicates the range (line numbers) on the other side of the comparison
    * where the code related to the current chunk came from/went to.
    */
-  range: {
+  range?: {
     start: number;
     end: number;
   };
@@ -253,6 +259,7 @@
    * be a temporary setting until the experiment is concluded.
    */
   use_lit_components?: boolean;
+  show_sign_col?: boolean;
 }
 
 /**
@@ -333,6 +340,10 @@
   lineNum: LineNumber;
 }
 
+export declare interface RenderProgressEventDetail {
+  linesRendered: number;
+}
+
 export declare interface DisplayLine {
   side: Side;
   lineNum: LineNumber;
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 418cc67..b6fa7ee 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -92,6 +92,7 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi<T>;
+  // DEPRECATED: Just add <style> elements to `document.head`.
   registerStyleModule(endpoint: string, moduleName: string): void;
   reporting(): ReportingPluginApi;
   restApi(): RestPluginApi;
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index ab7e7bb..72712ff 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -48,7 +48,7 @@
 }
 
 /**
- * @desc Specifies status for a change
+ * Specifies status for a change
  */
 export enum ChangeStatus {
   ABANDONED = 'ABANDONED',
@@ -72,7 +72,7 @@
 }
 
 /**
- * @desc Used for server config of accounts
+ * Used for server config of accounts
  */
 export enum DefaultDisplayNameConfig {
   USERNAME = 'USERNAME',
@@ -91,7 +91,7 @@
 }
 
 /**
- * @desc The status of the file
+ * The status of the file
  */
 export enum FileInfoStatus {
   ADDED = 'A',
@@ -104,7 +104,7 @@
 }
 
 /**
- * @desc The status of the file
+ * The status of the file
  */
 export enum GpgKeyInfoStatus {
   BAD = 'BAD',
@@ -144,7 +144,7 @@
 }
 
 /**
- * @desc The status of fixing the problem
+ * The status of fixing the problem
  */
 export enum ProblemInfoStatus {
   FIXED = 'FIXED',
@@ -152,7 +152,7 @@
 }
 
 /**
- * @desc The state of the projects
+ * The state of the projects
  */
 export enum ProjectState {
   ACTIVE = 'ACTIVE',
@@ -161,7 +161,7 @@
 }
 
 /**
- * @desc The reviewer state
+ * The reviewer state
  */
 export enum RequirementStatus {
   OK = 'OK',
@@ -170,7 +170,7 @@
 }
 
 /**
- * @desc The reviewer state
+ * The reviewer state
  */
 export enum ReviewerState {
   REVIEWER = 'REVIEWER',
@@ -179,7 +179,7 @@
 }
 
 /**
- * @desc The patchset kind
+ * The patchset kind
  */
 export enum RevisionKind {
   REWORK = 'REWORK',
@@ -399,7 +399,7 @@
   actions?: ActionNameToActionInfoMap;
   requirements?: Requirement[];
   labels?: LabelNameToInfoMap;
-  permitted_labels?: LabelNameToValueMap;
+  permitted_labels?: LabelNameToValuesMap;
   removable_reviewers?: AccountInfo[];
   // This is documented as optional, but actually always set.
   reviewers: Reviewers;
@@ -734,7 +734,7 @@
 export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
 
 // {Verified: ["-1", " 0", "+1"]}
-export type LabelNameToValueMap = {[labelName: string]: string[]};
+export type LabelNameToValuesMap = {[labelName: string]: string[]};
 
 /**
  * The LabelInfo entity contains information about a label on a change, always
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 6ed9646..a0a6417 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -16,7 +16,7 @@
  */
 
 /**
- * @desc Tab names for primary tabs on change view page.
+ * Tab names for primary tabs on change view page.
  */
 import {DiffViewMode} from '../api/diff';
 import {DiffPreferencesInfo} from '../types/diff';
@@ -74,14 +74,14 @@
 }
 
 /**
- * @desc Tab names for secondary tabs on change view page.
+ * Tab names for secondary tabs on change view page.
  */
 export enum SecondaryTab {
   CHANGE_LOG = '_changeLog',
 }
 
 /**
- * @desc Tag names of change log messages.
+ * Tag names of change log messages.
  */
 export enum MessageTag {
   TAG_DELETE_REVIEWER = 'autogenerated:gerrit:deleteReviewer',
@@ -97,7 +97,32 @@
 }
 
 /**
- * @desc Modes for gr-diff-cursor
+ * @description These values are directly displayed in the dialog to show progress of
+ * change.
+ */
+export enum ProgressStatus {
+  RUNNING = 'RUNNING',
+  FAILED = 'FAILED',
+  NOT_STARTED = 'NOT STARTED',
+  SUCCESSFUL = 'SUCCESSFUL',
+}
+
+export enum ColumnNames {
+  SUBJECT = 'Subject',
+  // TODO(milutin) - remove once Submit Requirements are rolled out.
+  STATUS = 'Status',
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  COMMENTS = 'Comments',
+  REPO = 'Repo',
+  BRANCH = 'Branch',
+  UPDATED = 'Updated',
+  SIZE = 'Size',
+  STATUS2 = ' Status ', // spaces to differentiate from old 'Status'
+}
+
+/**
+ * @description Modes for gr-diff-cursor
  * The scroll behavior for the cursor. Values are 'never' and
  * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
  * the viewport.
@@ -108,7 +133,7 @@
 }
 
 /**
- * @desc Special file paths
+ * Special file paths
  */
 export enum SpecialFilePath {
   PATCHSET_LEVEL_COMMENTS = '/PATCHSET_LEVEL',
@@ -249,14 +274,19 @@
   NONE = 'NONE',
 }
 
-// TODO(TS): Many properties are omitted here, but they are required.
-// Add default values for missing properties.
-export function createDefaultPreferences() {
+export function createDefaultPreferences(): PreferencesInfo {
   return {
     changes_per_page: 25,
     diff_view: DiffViewMode.SIDE_BY_SIDE,
     size_bar_in_change_table: true,
-  } as PreferencesInfo;
+    my: [],
+    theme: AppTheme.LIGHT,
+    date_format: DateFormat.EURO,
+    time_format: TimeFormat.HHMM_24,
+    change_table: [],
+    email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
+    default_base_for_merges: DefaultBase.AUTO_MERGE,
+  };
 }
 
 // These defaults should match the defaults in
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
index 5b4a534..850a5d2 100644
--- a/polygerrit-ui/app/constants/messages.ts
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -15,6 +15,6 @@
  * limitations under the License.
  */
 
-/** @desc Message shown when no threads in gr-thread-list for robot comments */
+/** Message shown when no threads in gr-thread-list for robot comments */
 export const NO_ROBOT_COMMENTS_THREADS_MSG =
   'There are no findings for this patchset.';
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index cc7ff29..d459886 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -33,6 +33,7 @@
   METHOD_USED = 'method used',
   CHECKS_API_NOT_LOGGED_IN = 'checks-api not-logged-in',
   CHECKS_API_ERROR = 'checks-api error',
+  USER_PREFERENCES_COLUMNS = 'user-preferences-columns',
 }
 
 export enum Timing {
@@ -46,10 +47,8 @@
   DASHBOARD_DISPLAYED = 'DashboardDisplayed',
   // Time from navigation to showing full content of diff without highlighting layer
   DIFF_VIEW_CONTENT_DISPLAYED = 'DiffViewOnlyContent',
-  // Time from navigation to showing viewport (> 120 lines) of diff with highlighting layer.
-  DIFF_VIEW_DISPLAYED = 'DiffViewDisplayed',
   // Time from navigation to showing full content of diff
-  DIFF_VIEW_LOAD_FULL = 'DiffViewFullyLoaded',
+  DIFF_VIEW_DISPLAYED = 'DiffViewDisplayed',
   // Time from navigation to showing initial content of the file list.
   FILE_LIST_DISPLAYED = 'FileListDisplayed',
   // Time from startup to having loaded all plugins.
@@ -64,10 +63,8 @@
   STARTUP_DASHBOARD_DISPLAYED = 'StartupDashboardDisplayed',
   // Time from startup to showing full content of diff without highlighting layer
   STARTUP_DIFF_VIEW_CONTENT_DISPLAYED = 'StartupDiffViewOnlyContent',
-  // Time from startup to showing viewport (> 120 lines) of diff with highlighting layer.
-  STARTUP_DIFF_VIEW_DISPLAYED = 'StartupDiffViewDisplayed',
   // Time from startup to showing full content of diff view.
-  STARTUP_DIFF_VIEW_LOAD_FULL = 'StartupDiffViewFullyLoaded',
+  STARTUP_DIFF_VIEW_DISPLAYED = 'StartupDiffViewDisplayed',
   // Time from startup to showing initial content of the file list.
   STARTUP_FILE_LIST_DISPLAYED = 'StartupFileListDisplayed',
   // Time from startup to when the webcomponentsready event is fired. If the event is fired from the webcomponents-lite polyfill, this may be arbitrarily long after the app has started.
@@ -82,7 +79,7 @@
   DIFF_TOTAL = 'Diff Total Render',
   // The time to render the content off a diff (excluding loading of data or syntax highlighting).
   DIFF_CONTENT = 'Diff Content Render',
-  // Time to compute and render the syntax highlighting of a diff.
+  // Time to compute syntax highlighting of a diff  minus diff rendering time (DIFF_CONTENT).
   DIFF_SYNTAX = 'Diff Syntax Render',
   // Time to load diff and prepare before gr-diff rendering begins.
   DIFF_LOAD = 'Diff Load Render',
@@ -108,6 +105,7 @@
   COMMENT_SAVED = 'comment-saved',
   DISCARD_COMMENT = 'discard-comment',
   COMMENT_DISCARDED = 'comment-discarded',
+  ROBOT_COMMENTS_STATS = 'robot-comments-stats',
   CHECKS_TAB_RENDERED = 'checks-tab-rendered',
   CHECKS_CHIP_CLICKED = 'checks-chip-clicked',
   CHECKS_CHIP_LINK_CLICKED = 'checks-chip-link-clicked',
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 48f42a4..8fa2e90 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -14,22 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-permission/gr-permission';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-access-section_html';
 import {
   AccessPermissions,
   PermissionArray,
   PermissionArrayItem,
   toSortedPermissionsArray,
 } from '../../../utils/access-util';
-import {customElement, property} from '@polymer/decorators';
 import {
   EditablePermissionInfo,
   PermissionAccessSection,
@@ -41,8 +36,15 @@
   LabelNameToLabelTypeInfoMap,
   RepoName,
 } from '../../../types/common';
-import {PolymerDomRepeatEvent} from '../../../types/types';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
 /**
  * Fired when the section has been modified or removed.
@@ -64,17 +66,9 @@
 const ON_BEHALF_OF = '(On Behalf Of)';
 const LABEL = 'Label';
 
-export interface GrAccessSection {
-  $: {
-    permissionSelect: HTMLSelectElement;
-  };
-}
-
 @customElement('gr-access-section')
-export class GrAccessSection extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAccessSection extends LitElement {
+  @query('#permissionSelect') private permissionSelect?: HTMLSelectElement;
 
   @property({type: String})
   repo?: RepoName;
@@ -82,7 +76,7 @@
   @property({type: Object})
   capabilities?: CapabilityInfoMap;
 
-  @property({type: Object, notify: true, observer: '_updateSection'})
+  @property({type: Object})
   section?: PermissionAccessSection;
 
   @property({type: Object})
@@ -91,7 +85,7 @@
   @property({type: Object})
   labels?: LabelNameToLabelTypeInfoMap;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
+  @property({type: Boolean})
   editing = false;
 
   @property({type: Boolean})
@@ -100,43 +94,220 @@
   @property({type: Array})
   ownerOf?: GitRef[];
 
-  @property({type: String})
-  _originalId?: GitRef;
+  // private but used in test
+  @state() originalId?: GitRef;
 
-  @property({type: Boolean})
-  _editingRef = false;
+  // private but used in test
+  @state() editingRef = false;
 
-  @property({type: Boolean})
-  _deleted = false;
+  // private but used in test
+  @state() deleted = false;
 
-  @property({type: Array})
-  _permissions?: PermissionArray<EditablePermissionInfo>;
+  // private but used in test
+  @state() permissions?: PermissionArray<EditablePermissionInfo>;
 
   constructor() {
     super();
-    this.addEventListener('access-saved', () => this._handleAccessSaved());
+    this.addEventListener('access-saved', () => this.handleAccessSaved());
   }
 
-  _updateSection(section: PermissionAccessSection) {
-    this._permissions = toSortedPermissionsArray(section.value.permissions);
-    this._originalId = section.id;
+  static override get styles() {
+    return [
+      formStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          margin-bottom: var(--spacing-l);
+        }
+        fieldset {
+          border: 1px solid var(--border-color);
+        }
+        .name {
+          align-items: center;
+          display: flex;
+        }
+        .header,
+        #deletedContainer {
+          align-items: center;
+          background: var(--table-header-background-color);
+          border-bottom: 1px dotted var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          min-height: 3em;
+          padding: 0 var(--spacing-m);
+        }
+        #deletedContainer {
+          border-bottom: 0;
+        }
+        .sectionContent {
+          padding: var(--spacing-m);
+        }
+        #editBtn,
+        .editing #editBtn.global,
+        #deletedContainer,
+        .deleted #mainContainer,
+        #addPermission,
+        #deleteBtn,
+        .editingRef .name,
+        .editRefInput {
+          display: none;
+        }
+        .editing #editBtn,
+        .editingRef .editRefInput {
+          display: flex;
+        }
+        .deleted #deletedContainer {
+          display: flex;
+        }
+        .editing #addPermission,
+        #mainContainer,
+        .editing #deleteBtn {
+          display: block;
+        }
+        .editing #deleteBtn,
+        #undoRemoveBtn {
+          padding-right: var(--spacing-m);
+        }
+      `,
+    ];
   }
 
-  _handleAccessSaved() {
-    if (!this.section) {
-      return;
+  override render() {
+    if (!this.section) return;
+    return html`
+      <fieldset
+        id="section"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        <div id="mainContainer">
+          <div class="header">
+            <div class="name">
+              <h3 class="heading-3">${this.computeSectionName()}</h3>
+              <gr-button
+                id="editBtn"
+                link
+                class=${this.section?.id === GLOBAL_NAME ? 'global' : ''}
+                @click=${this.editReference}
+              >
+                <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
+              </gr-button>
+            </div>
+            <iron-input
+              class="editRefInput"
+              .bindValue=${this.section?.id}
+              @input=${this.handleValueChange}
+              @bind-value-changed=${this.handleIdBindValueChanged}
+            >
+              <input
+                class="editRefInput"
+                type="text"
+                @input=${this.handleValueChange}
+              />
+            </iron-input>
+            <gr-button link id="deleteBtn" @click=${this.handleRemoveReference}
+              >Remove</gr-button
+            >
+          </div>
+          <!-- end header -->
+          <div class="sectionContent">
+            ${this.permissions?.map((permission, index) =>
+              this.renderPermission(permission, index)
+            )}
+            <div id="addPermission">
+              Add permission:
+              <select id="permissionSelect">
+                ${this.computePermissions().map(item =>
+                  this.renderPermissionOptions(item)
+                )}
+              </select>
+              <gr-button link id="addBtn" @click=${this.handleAddPermission}
+                >Add</gr-button
+              >
+            </div>
+            <!-- end addPermission -->
+          </div>
+          <!-- end sectionContent -->
+        </div>
+        <!-- end mainContainer -->
+        <div id="deletedContainer">
+          <span>${this.computeSectionName()} was deleted</span>
+          <gr-button link="" id="undoRemoveBtn" @click=${this._handleUndoRemove}
+            >Undo</gr-button
+          >
+        </div>
+        <!-- end deletedContainer -->
+      </fieldset>
+    `;
+  }
+
+  private renderPermission(
+    permission: PermissionArrayItem<EditablePermissionInfo>,
+    index: number
+  ) {
+    return html`
+      <gr-permission
+        .name=${this.computePermissionName(permission)}
+        .permission=${permission}
+        .labels=${this.labels}
+        .section=${this.section?.id}
+        .editing=${this.editing}
+        .groups=${this.groups}
+        .repo=${this.repo}
+        @added-permission-removed=${() => {
+          this.handleAddedPermissionRemoved(index);
+        }}
+        @permission-changed=${(
+          e: ValueChangedEvent<PermissionArrayItem<EditablePermissionInfo>>
+        ) => {
+          this.handlePermissionChanged(e, index);
+        }}
+      >
+      </gr-permission>
+    `;
+  }
+
+  private renderPermissionOptions(item: {
+    id: string;
+    value: {name: string; id: string};
+  }) {
+    return html`<option value=${item.value.id}>${item.value.name}</option>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('section')) {
+      this.updateSection();
     }
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing') as boolean);
+    }
+  }
+
+  // private but used in test
+  updateSection() {
+    this.permissions = toSortedPermissionsArray(
+      this.section!.value.permissions
+    );
+    this.originalId = this.section!.id;
+  }
+
+  // private but used in test
+  handleAccessSaved() {
+    if (!this.section) return;
     // Set a new 'original' value to keep track of after the value has been
     // saved.
-    this._updateSection(this.section);
+    this.updateSection();
   }
 
-  _handleValueChange() {
+  // private but used in test
+  handleValueChange() {
     if (!this.section) {
       return;
     }
     if (!this.section.value.added) {
-      this.section.value.modified = this.section.id !== this._originalId;
+      this.section.value.modified = this.section.id !== this.originalId;
+      this.requestUpdate();
       // Allows overall access page to know a change has been made.
       // For a new section, this is not fired because new permissions and
       // rules have to be added in order to save, modifying the ref is not
@@ -144,25 +315,28 @@
       fireEvent(this, 'access-modified');
     }
     this.section.value.updatedId = this.section.id;
+    this.requestUpdate();
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
     if (!editingOld) {
       return;
     }
-    if (!this.section || !this._permissions) {
+    if (!this.section || !this.permissions) {
       return;
     }
     // Restore original values if no longer editing.
-    if (!editing) {
-      this._editingRef = false;
-      this._deleted = false;
+    if (!this.editing) {
+      this.editingRef = false;
+      this.deleted = false;
       delete this.section.value.deleted;
       // Restore section ref.
-      this.set(['section', 'id'], this._originalId);
+      this.section.id = this.originalId as GitRef;
+      this.requestUpdate();
+      fire(this, 'section-changed', {value: this.section});
       // Remove any unsaved but added permissions.
-      this._permissions = this._permissions.filter(p => !p.value.added);
+      this.permissions = this.permissions.filter(p => !p.value.added);
       for (const key of Object.keys(this.section.value.permissions)) {
         if (this.section.value.permissions[key].added) {
           delete this.section.value.permissions[key];
@@ -171,22 +345,17 @@
     }
   }
 
-  _computePermissions(
-    name: string,
-    capabilities?: CapabilityInfoMap,
-    labels?: LabelNameToLabelTypeInfoMap,
-    // This is just for triggering re-computation. We don't use the value.
-    _?: unknown
-  ) {
+  // private but used in test
+  computePermissions() {
     let allPermissions;
     const section = this.section;
     if (!section || !section.value) {
       return [];
     }
-    if (name === GLOBAL_NAME) {
-      allPermissions = toSortedPermissionsArray(capabilities);
+    if (section.id === GLOBAL_NAME) {
+      allPermissions = toSortedPermissionsArray(this.capabilities);
     } else {
-      const labelOptions = this._computeLabelOptions(labels);
+      const labelOptions = this.computeLabelOptions();
       allPermissions = labelOptions.concat(
         toSortedPermissionsArray(AccessPermissions)
       );
@@ -196,22 +365,21 @@
     );
   }
 
-  _handleAddedPermissionRemoved(e: PolymerDomRepeatEvent) {
-    if (!this._permissions) {
+  private handleAddedPermissionRemoved(index: number) {
+    if (!this.permissions) {
       return;
     }
-    const index = e.model.index;
-    this._permissions = this._permissions
+    this.permissions = this.permissions
       .slice(0, index)
-      .concat(this._permissions.slice(index + 1, this._permissions.length));
+      .concat(this.permissions.slice(index + 1, this.permissions.length));
   }
 
-  _computeLabelOptions(labels?: LabelNameToLabelTypeInfoMap) {
+  computeLabelOptions() {
     const labelOptions = [];
-    if (!labels) {
+    if (!this.labels) {
       return [];
     }
-    for (const labelName of Object.keys(labels)) {
+    for (const labelName of Object.keys(this.labels)) {
       labelOptions.push({
         id: 'label-' + labelName,
         value: {
@@ -230,13 +398,12 @@
     return labelOptions;
   }
 
-  _computePermissionName(
-    name: string,
-    permission: PermissionArrayItem<EditablePermissionInfo>,
-    capabilities?: CapabilityInfoMap
+  // private but used in test
+  computePermissionName(
+    permission: PermissionArrayItem<EditablePermissionInfo>
   ): string | undefined {
-    if (name === GLOBAL_NAME) {
-      return capabilities?.[permission.id].name;
+    if (this.section?.id === GLOBAL_NAME) {
+      return this.capabilities?.[permission.id].name;
     } else if (AccessPermissions[permission.id]) {
       return AccessPermissions[permission.id].name;
     } else if (permission.value.label) {
@@ -249,15 +416,19 @@
     return undefined;
   }
 
-  _computeSectionName(name: string) {
+  // private but used in test
+  computeSectionName() {
+    let name = this.section?.id;
     // When a new section is created, it doesn't yet have a ref. Set into
     // edit mode so that the user can input one.
     if (!name) {
-      this._editingRef = true;
+      this.editingRef = true;
       // Needed for the title value. This is the same default as GWT.
-      name = NEW_NAME;
+      name = NEW_NAME as GitRef;
       // Needed for the input field value.
-      this.set('section.id', name);
+      this.section!.id = name;
+      fire(this, 'section-changed', {value: this.section!});
+      this.requestUpdate();
     }
     if (name === GLOBAL_NAME) {
       return 'Global Capabilities';
@@ -267,14 +438,14 @@
     return name;
   }
 
-  _handleRemoveReference() {
+  private handleRemoveReference() {
     if (!this.section) {
       return;
     }
     if (this.section.value.added) {
       fireEvent(this, 'added-section-removed');
     }
-    this._deleted = true;
+    this.deleted = true;
     this.section.value.deleted = true;
     fireEvent(this, 'access-modified');
   }
@@ -283,61 +454,46 @@
     if (!this.section) {
       return;
     }
-    this._deleted = false;
+    this.deleted = false;
     delete this.section.value.deleted;
+    this.requestUpdate();
   }
 
   editRefInput() {
-    return this.root!.querySelector(
-      PolymerElement
-        ? 'iron-input.editRefInput'
-        : 'input[is=iron-input].editRefInput'
-    ) as HTMLInputElement;
+    return queryAndAssert<IronInputElement>(this, 'iron-input.editRefInput');
   }
 
   editReference() {
-    this._editingRef = true;
+    this.editingRef = true;
     this.editRefInput().focus();
   }
 
-  _isEditEnabled(
-    canUpload: boolean | undefined,
-    ownerOf: GitRef[] | undefined,
-    sectionId: GitRef
-  ) {
-    return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
+  private isEditEnabled() {
+    return (
+      this.canUpload ||
+      (this.ownerOf && this.ownerOf.indexOf(this.section!.id) >= 0)
+    );
   }
 
-  _computeSectionClass(
-    editing: boolean,
-    canUpload: boolean | undefined,
-    ownerOf: GitRef[] | undefined,
-    editingRef: boolean,
-    deleted: boolean
-  ) {
+  // private but used in test
+  computeSectionClass() {
     const classList = [];
-    if (
-      editing &&
-      this.section &&
-      this._isEditEnabled(canUpload, ownerOf, this.section.id)
-    ) {
+    if (this.editing && this.section && this.isEditEnabled()) {
       classList.push('editing');
     }
-    if (editingRef) {
+    if (this.editingRef) {
       classList.push('editingRef');
     }
-    if (deleted) {
+    if (this.deleted) {
       classList.push('deleted');
     }
     return classList.join(' ');
   }
 
-  _computeEditBtnClass(name: string) {
-    return name === GLOBAL_NAME ? 'global' : '';
-  }
-
-  _handleAddPermission() {
-    const value = this.$.permissionSelect.value as GitRef;
+  // private but used in test
+  handleAddPermission() {
+    assertIsDefined(this.permissionSelect, 'permissionSelect');
+    const value = this.permissionSelect.value as GitRef;
     const permission: PermissionArrayItem<EditablePermissionInfo> = {
       id: value,
       value: {rules: {}, added: true},
@@ -365,12 +521,31 @@
     }
     // Add to the end of the array (used in dom-repeat) and also to the
     // section object that is two way bound with its parent element.
-    this.push('_permissions', permission);
-    this.set(['section.value.permissions', permission.id], permission.value);
+    this.permissions!.push(permission);
+    this.section!.value.permissions[permission.id] = permission.value;
+    this.requestUpdate();
+    fire(this, 'section-changed', {value: this.section!});
   }
+
+  private handleIdBindValueChanged = (e: BindValueChangeEvent) => {
+    this.section!.id = e.detail.value as GitRef;
+    this.requestUpdate();
+    fire(this, 'section-changed', {value: this.section!});
+  };
+
+  private handlePermissionChanged = (
+    e: ValueChangedEvent<PermissionArrayItem<EditablePermissionInfo>>,
+    index: number
+  ) => {
+    this.permissions![index] = e.detail.value;
+    this.requestUpdate();
+  };
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'section-changed': ValueChangedEvent<PermissionAccessSection>;
+  }
   interface HTMLElementTagNameMap {
     'gr-access-section': GrAccessSection;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
deleted file mode 100644
index 65a3199..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-l);
-    }
-    fieldset {
-      border: 1px solid var(--border-color);
-    }
-    .name {
-      align-items: center;
-      display: flex;
-    }
-    .header,
-    #deletedContainer {
-      align-items: center;
-      background: var(--table-header-background-color);
-      border-bottom: 1px dotted var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      min-height: 3em;
-      padding: 0 var(--spacing-m);
-    }
-    #deletedContainer {
-      border-bottom: 0;
-    }
-    .sectionContent {
-      padding: var(--spacing-m);
-    }
-    #editBtn,
-    .editing #editBtn.global,
-    #deletedContainer,
-    .deleted #mainContainer,
-    #addPermission,
-    #deleteBtn,
-    .editingRef .name,
-    .editRefInput {
-      display: none;
-    }
-    .editing #editBtn,
-    .editingRef .editRefInput {
-      display: flex;
-    }
-    .deleted #deletedContainer {
-      display: flex;
-    }
-    .editing #addPermission,
-    #mainContainer,
-    .editing #deleteBtn {
-      display: block;
-    }
-    .editing #deleteBtn,
-    #undoRemoveBtn {
-      padding-right: var(--spacing-m);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <fieldset
-    id="section"
-    class$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]"
-  >
-    <div id="mainContainer">
-      <div class="header">
-        <div class="name">
-          <h3 class="heading-3">[[_computeSectionName(section.id)]]</h3>
-          <gr-button
-            id="editBtn"
-            link=""
-            class$="[[_computeEditBtnClass(section.id)]]"
-            on-click="editReference"
-          >
-            <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
-          </gr-button>
-        </div>
-        <iron-input
-          class="editRefInput"
-          bind-value="{{section.id}}"
-          type="text"
-          on-input="_handleValueChange"
-        >
-          <input
-            class="editRefInput"
-            bind-value="{{section.id}}"
-            is="iron-input"
-            type="text"
-            on-input="_handleValueChange"
-          />
-        </iron-input>
-        <gr-button link="" id="deleteBtn" on-click="_handleRemoveReference"
-          >Remove</gr-button
-        >
-      </div>
-      <!-- end header -->
-      <div class="sectionContent">
-        <template is="dom-repeat" items="{{_permissions}}" as="permission">
-          <gr-permission
-            name="[[_computePermissionName(section.id, permission, capabilities)]]"
-            permission="{{permission}}"
-            labels="[[labels]]"
-            section="[[section.id]]"
-            editing="[[editing]]"
-            groups="[[groups]]"
-            repo="[[repo]]"
-            on-added-permission-removed="_handleAddedPermissionRemoved"
-          >
-          </gr-permission>
-        </template>
-        <div id="addPermission">
-          Add permission:
-          <select id="permissionSelect">
-            <!-- called with a third parameter so that permissions update
-                  after a new section is added. -->
-            <template
-              is="dom-repeat"
-              items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]"
-            >
-              <option value="[[item.value.id]]">[[item.value.name]]</option>
-            </template>
-          </select>
-          <gr-button link="" id="addBtn" on-click="_handleAddPermission"
-            >Add</gr-button
-          >
-        </div>
-        <!-- end addPermission -->
-      </div>
-      <!-- end sectionContent -->
-    </div>
-    <!-- end mainContainer -->
-    <div id="deletedContainer">
-      <span>[[_computeSectionName(section.id)]] was deleted</span>
-      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-        >Undo</gr-button
-      >
-    </div>
-    <!-- end deletedContainer -->
-  </fieldset>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
deleted file mode 100644
index 7182ede..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
+++ /dev/null
@@ -1,521 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-access-section.js';
-import {AccessPermissions, toSortedPermissionsArray} from '../../../utils/access-util.js';
-
-const fixture = fixtureFromElement('gr-access-section');
-
-suite('gr-access-section tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture.instantiate();
-  });
-
-  suite('unit tests', () => {
-    setup(() => {
-      element.section = {
-        id: 'refs/*',
-        value: {
-          permissions: {
-            read: {
-              rules: {},
-            },
-          },
-        },
-      };
-      element.capabilities = {
-        accessDatabase: {
-          id: 'accessDatabase',
-          name: 'Access Database',
-        },
-        administrateServer: {
-          id: 'administrateServer',
-          name: 'Administrate Server',
-        },
-        batchChangesLimit: {
-          id: 'batchChangesLimit',
-          name: 'Batch Changes Limit',
-        },
-        createAccount: {
-          id: 'createAccount',
-          name: 'Create Account',
-        },
-      };
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-      element._updateSection(element.section);
-      flush();
-    });
-
-    test('_updateSection', () => {
-      // _updateSection was called in setup, so just make assertions.
-      const expectedPermissions = [
-        {
-          id: 'read',
-          value: {
-            rules: {},
-          },
-        },
-      ];
-      assert.deepEqual(element._permissions, expectedPermissions);
-      assert.equal(element._originalId, element.section.id);
-    });
-
-    test('_computeLabelOptions', () => {
-      const expectedLabelOptions = [
-        {
-          id: 'label-Code-Review',
-          value: {
-            name: 'Label Code-Review',
-            id: 'label-Code-Review',
-          },
-        },
-        {
-          id: 'labelAs-Code-Review',
-          value: {
-            name: 'Label Code-Review (On Behalf Of)',
-            id: 'labelAs-Code-Review',
-          },
-        },
-      ];
-
-      assert.deepEqual(element._computeLabelOptions(element.labels),
-          expectedLabelOptions);
-    });
-
-    test('_handleAccessSaved', () => {
-      assert.equal(element._originalId, 'refs/*');
-      element.section.id = 'refs/for/bar';
-      element._handleAccessSaved();
-      assert.equal(element._originalId, 'refs/for/bar');
-    });
-
-    test('_computePermissions', () => {
-      const capabilities = {
-        push: {
-          rules: {},
-        },
-        read: {
-          rules: {},
-        },
-      };
-
-      const expectedPermissions = [{
-        id: 'push',
-        value: {
-          rules: {},
-        },
-      },
-      ];
-      const labelOptions = [
-        {
-          id: 'label-Code-Review',
-          value: {
-            name: 'Label Code-Review',
-            id: 'label-Code-Review',
-          },
-        },
-        {
-          id: 'labelAs-Code-Review',
-          value: {
-            name: 'Label Code-Review (On Behalf Of)',
-            id: 'labelAs-Code-Review',
-          },
-        },
-      ];
-
-      // For global capabilities, just return the sorted array filtered by
-      // existing permissions.
-      let name = 'GLOBAL_CAPABILITIES';
-      assert.deepEqual(element._computePermissions(name, capabilities,
-          element.labels), expectedPermissions);
-
-      // For everything else, include possible label values before filtering.
-      name = 'refs/for/*';
-      assert.deepEqual(
-          element._computePermissions(name, capabilities, element.labels),
-          labelOptions
-              .concat(toSortedPermissionsArray(AccessPermissions))
-              .filter(permission => permission.id !== 'read'));
-    });
-
-    test('_computePermissionName', () => {
-      let name = 'GLOBAL_CAPABILITIES';
-      let permission = {
-        id: 'administrateServer',
-        value: {},
-      };
-      assert.equal(element._computePermissionName(name, permission,
-          element.capabilities),
-      element.capabilities[permission.id].name);
-
-      name = 'refs/for/*';
-      permission = {
-        id: 'abandon',
-        value: {},
-      };
-
-      assert.equal(element._computePermissionName(
-          name, permission, element.capabilities),
-      AccessPermissions[permission.id].name);
-
-      name = 'refs/for/*';
-      permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-        },
-      };
-
-      assert.equal(element._computePermissionName(name, permission,
-          element.capabilities),
-      'Label Code-Review');
-
-      permission = {
-        id: 'labelAs-Code-Review',
-        value: {
-          label: 'Code-Review',
-        },
-      };
-
-      assert.equal(element._computePermissionName(name, permission,
-          element.capabilities),
-      'Label Code-Review(On Behalf Of)');
-    });
-
-    test('_computeSectionName', () => {
-      let name;
-      // When computing the section name for an undefined name, it means a
-      // new section is being added. In this case, it should default to
-      // 'refs/heads/*'.
-      element._editingRef = false;
-      assert.equal(element._computeSectionName(name),
-          'Reference: refs/heads/*');
-      assert.isTrue(element._editingRef);
-      assert.equal(element.section.id, 'refs/heads/*');
-
-      // Reset editing to false.
-      element._editingRef = false;
-      name = 'GLOBAL_CAPABILITIES';
-      assert.equal(element._computeSectionName(name), 'Global Capabilities');
-      assert.isFalse(element._editingRef);
-
-      name = 'refs/for/*';
-      assert.equal(element._computeSectionName(name),
-          'Reference: refs/for/*');
-      assert.isFalse(element._editingRef);
-    });
-
-    test('editReference', () => {
-      element.editReference();
-      assert.isTrue(element._editingRef);
-    });
-
-    test('_computeSectionClass', () => {
-      let editingRef = false;
-      let canUpload = false;
-      let ownerOf = [];
-      let editing = false;
-      let deleted = false;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), '');
-
-      ownerOf = ['refs/*'];
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing');
-
-      ownerOf = [];
-      canUpload = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing');
-
-      editingRef = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing editingRef');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing editingRef deleted');
-
-      editingRef = false;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing deleted');
-    });
-
-    test('_computeEditBtnClass', () => {
-      let name = 'GLOBAL_CAPABILITIES';
-      assert.equal(element._computeEditBtnClass(name), 'global');
-      name = 'refs/for/*';
-      assert.equal(element._computeEditBtnClass(name), '');
-    });
-  });
-
-  suite('interactive tests', () => {
-    setup(() => {
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-    });
-    suite('Global section', () => {
-      setup(() => {
-        element.section = {
-          id: 'GLOBAL_CAPABILITIES',
-          value: {
-            permissions: {
-              accessDatabase: {
-                rules: {},
-              },
-            },
-          },
-        };
-        element.capabilities = {
-          accessDatabase: {
-            id: 'accessDatabase',
-            name: 'Access Database',
-          },
-          administrateServer: {
-            id: 'administrateServer',
-            name: 'Administrate Server',
-          },
-          batchChangesLimit: {
-            id: 'batchChangesLimit',
-            name: 'Batch Changes Limit',
-          },
-          createAccount: {
-            id: 'createAccount',
-            name: 'Create Account',
-          },
-        };
-        element._updateSection(element.section);
-        flush();
-      });
-
-      test('classes are assigned correctly', () => {
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        assert.isFalse(element.$.section.classList.contains('deleted'));
-        assert.isTrue(element.$.editBtn.classList.contains('global'));
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-    });
-
-    suite('Non-global section', () => {
-      setup(() => {
-        element.section = {
-          id: 'refs/*',
-          value: {
-            permissions: {
-              read: {
-                rules: {},
-              },
-            },
-          },
-        };
-        element.capabilities = {};
-        element._updateSection(element.section);
-        flush();
-      });
-
-      test('classes are assigned correctly', () => {
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        assert.isFalse(element.$.section.classList.contains('deleted'));
-        assert.isFalse(element.$.editBtn.classList.contains('global'));
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        flush();
-        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-
-      test('add permission', () => {
-        element.editing = true;
-        element.$.permissionSelect.value = 'label-Code-Review';
-        assert.equal(element._permissions.length, 1);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            1);
-        MockInteractions.tap(element.$.addBtn);
-        flush();
-
-        // The permission is added to both the permissions array and also
-        // the section's permission object.
-        assert.equal(element._permissions.length, 2);
-        let permission = {
-          id: 'label-Code-Review',
-          value: {
-            added: true,
-            label: 'Code-Review',
-            rules: {},
-          },
-        };
-        assert.equal(element._permissions.length, 2);
-        assert.deepEqual(element._permissions[1], permission);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            2);
-        assert.deepEqual(
-            element.section.value.permissions['label-Code-Review'],
-            permission.value);
-
-        element.$.permissionSelect.value = 'abandon';
-        MockInteractions.tap(element.$.addBtn);
-        flush();
-
-        permission = {
-          id: 'abandon',
-          value: {
-            added: true,
-            rules: {},
-          },
-        };
-
-        assert.equal(element._permissions.length, 3);
-        assert.deepEqual(element._permissions[2], permission);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            3);
-        assert.deepEqual(element.section.value.permissions['abandon'],
-            permission.value);
-
-        // Unsaved changes are discarded when editing is cancelled.
-        element.editing = false;
-        assert.equal(element._permissions.length, 1);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            1);
-      });
-
-      test('edit section reference', async () => {
-        element.canUpload = true;
-        element.ownerOf = [];
-        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        element.editing = true;
-        assert.isTrue(element.$.section.classList.contains('editing'));
-        assert.isFalse(element._editingRef);
-        MockInteractions.tap(element.$.editBtn);
-        element.editRefInput().bindValue='new/ref';
-        await flush();
-        assert.equal(element.section.id, 'new/ref');
-        assert.isTrue(element._editingRef);
-        assert.isTrue(element.$.section.classList.contains('editingRef'));
-        element.editing = false;
-        assert.isFalse(element._editingRef);
-        assert.equal(element.section.id, 'refs/for/bar');
-      });
-
-      test('_handleValueChange', () => {
-        // For an existing section.
-        const modifiedHandler = sinon.stub();
-        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-        assert.notOk(element.section.value.updatedId);
-        element.section.id = 'refs/for/baz';
-        element.addEventListener('access-modified', modifiedHandler);
-        assert.isNotOk(element.section.value.modified);
-        element._handleValueChange();
-        assert.equal(element.section.value.updatedId, 'refs/for/baz');
-        assert.isTrue(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 1);
-        element.section.id = 'refs/for/bar';
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-
-        // For a new section.
-        element.section.value.added = true;
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-        element.section.id = 'refs/for/bar';
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-      });
-
-      test('remove section', () => {
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-        MockInteractions.tap(element.$.deleteBtn);
-        flush();
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.section.value.deleted);
-        assert.isTrue(element.$.section.classList.contains('deleted'));
-        assert.isTrue(element.section.value.deleted);
-
-        MockInteractions.tap(element.$.undoRemoveBtn);
-        flush();
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-
-        MockInteractions.tap(element.$.deleteBtn);
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.section.value.deleted);
-        element.editing = false;
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-      });
-
-      test('removing an added permission', () => {
-        element.editing = true;
-        assert.equal(element._permissions.length, 1);
-        element.shadowRoot
-            .querySelector('gr-permission').dispatchEvent(
-                new CustomEvent('added-permission-removed', {
-                  composed: true, bubbles: true,
-                }));
-        flush();
-        assert.equal(element._permissions.length, 0);
-      });
-
-      test('remove an added section', () => {
-        const removeStub = sinon.stub();
-        element.addEventListener('added-section-removed', removeStub);
-        element.editing = true;
-        element.section.value.added = true;
-        MockInteractions.tap(element.$.deleteBtn);
-        assert.isTrue(removeStub.called);
-      });
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
new file mode 100644
index 0000000..f4d81c4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -0,0 +1,639 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-access-section';
+import {
+  AccessPermissions,
+  toSortedPermissionsArray,
+} from '../../../utils/access-util';
+import {GrAccessSection} from './gr-access-section';
+import {GitRef} from '../../../types/common';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+suite('gr-access-section tests', () => {
+  let element: GrAccessSection;
+
+  setup(async () => {
+    element = await fixture<GrAccessSection>(html`
+      <gr-access-section></gr-access-section>
+    `);
+  });
+
+  suite('unit tests', () => {
+    setup(async () => {
+      element.section = {
+        id: 'refs/*' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+      element.capabilities = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+        administrateServer: {
+          id: 'administrateServer',
+          name: 'Administrate Server',
+        },
+        batchChangesLimit: {
+          id: 'batchChangesLimit',
+          name: 'Batch Changes Limit',
+        },
+        createAccount: {
+          id: 'createAccount',
+          name: 'Create Account',
+        },
+      };
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+      element.updateSection();
+      await element.updateComplete;
+    });
+
+    test('updateSection', () => {
+      // updateSection was called in setup, so just make assertions.
+      const expectedPermissions = [
+        {
+          id: 'read' as GitRef,
+          value: {
+            rules: {},
+          },
+        },
+      ];
+      assert.deepEqual(element.permissions, expectedPermissions);
+      assert.equal(element.originalId, element.section!.id);
+    });
+
+    test('computeLabelOptions', () => {
+      const expectedLabelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      assert.deepEqual(element.computeLabelOptions(), expectedLabelOptions);
+    });
+
+    test('handleAccessSaved', () => {
+      assert.equal(element.originalId, 'refs/*' as GitRef);
+      element.section!.id = 'refs/for/bar' as GitRef;
+      element.handleAccessSaved();
+      assert.equal(element.originalId, 'refs/for/bar' as GitRef);
+    });
+
+    test('computePermissions', () => {
+      const capabilities = {
+        push: {
+          id: '',
+          name: '',
+          rules: {},
+        },
+        read: {
+          id: '',
+          name: '',
+          rules: {},
+        },
+      };
+
+      const expectedPermissions = [
+        {
+          id: 'push',
+          value: {
+            id: '',
+            name: '',
+            rules: {},
+          },
+        },
+      ];
+      const labelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      element.section = {
+        id: 'refs/*' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+
+      // For global capabilities, just return the sorted array filtered by
+      // existing permissions.
+      element.section = {
+        id: 'GLOBAL_CAPABILITIES' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+      element.capabilities = capabilities;
+      assert.deepEqual(element.computePermissions(), expectedPermissions);
+
+      // For everything else, include possible label values before filtering.
+      element.section.id = 'refs/for/*' as GitRef;
+      assert.deepEqual(
+        element.computePermissions(),
+        labelOptions
+          .concat(toSortedPermissionsArray(AccessPermissions))
+          .filter(permission => permission.id !== 'read')
+      );
+    });
+
+    test('computePermissionName', () => {
+      element.section = {
+        id: 'GLOBAL_CAPABILITIES' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+
+      let permission;
+
+      permission = {
+        id: 'administrateServer' as GitRef,
+        value: {rules: {}},
+      };
+      assert.equal(
+        element.computePermissionName(permission),
+        element.capabilities![permission.id].name
+      );
+
+      element.section.id = 'refs/for/*' as GitRef;
+      permission = {
+        id: 'abandon' as GitRef,
+        value: {rules: {}},
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        AccessPermissions[permission.id].name
+      );
+
+      element.section.id = 'refs/for/*' as GitRef;
+      permission = {
+        id: 'label-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {},
+        },
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        'Label Code-Review'
+      );
+
+      permission = {
+        id: 'labelAs-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {},
+        },
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        'Label Code-Review(On Behalf Of)'
+      );
+    });
+
+    test('computeSectionName', () => {
+      // When computing the section name for an undefined name, it means a
+      // new section is being added. In this case, it should default to
+      // 'refs/heads/*'.
+      element.editingRef = false;
+      element.section!.id = '' as GitRef;
+      assert.equal(element.computeSectionName(), 'Reference: refs/heads/*');
+      assert.isTrue(element.editingRef);
+      assert.equal(element.section!.id, 'refs/heads/*');
+
+      // Reset editing to false.
+      element.editingRef = false;
+      element.section!.id = 'GLOBAL_CAPABILITIES' as GitRef;
+      assert.equal(element.computeSectionName(), 'Global Capabilities');
+      assert.isFalse(element.editingRef);
+
+      element.section!.id = 'refs/for/*' as GitRef;
+      assert.equal(element.computeSectionName(), 'Reference: refs/for/*');
+      assert.isFalse(element.editingRef);
+    });
+
+    test('editReference', () => {
+      element.editReference();
+      assert.isTrue(element.editingRef);
+    });
+
+    test('computeSectionClass', () => {
+      element.editingRef = false;
+      element.canUpload = false;
+      element.ownerOf = [];
+      element.editing = false;
+      element.deleted = false;
+      assert.equal(element.computeSectionClass(), '');
+
+      element.editing = true;
+      assert.equal(element.computeSectionClass(), '');
+
+      element.ownerOf = ['refs/*' as GitRef];
+      assert.equal(element.computeSectionClass(), 'editing');
+
+      element.ownerOf = [];
+      element.canUpload = true;
+      assert.equal(element.computeSectionClass(), 'editing');
+
+      element.editingRef = true;
+      assert.equal(element.computeSectionClass(), 'editing editingRef');
+
+      element.deleted = true;
+      assert.equal(element.computeSectionClass(), 'editing editingRef deleted');
+
+      element.editingRef = false;
+      assert.equal(element.computeSectionClass(), 'editing deleted');
+    });
+  });
+
+  suite('interactive tests', () => {
+    setup(() => {
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+    });
+    suite('Global section', () => {
+      setup(async () => {
+        element.section = {
+          id: 'GLOBAL_CAPABILITIES' as GitRef,
+          value: {
+            permissions: {
+              accessDatabase: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {
+          accessDatabase: {
+            id: 'accessDatabase',
+            name: 'Access Database',
+          },
+          administrateServer: {
+            id: 'administrateServer',
+            name: 'Administrate Server',
+          },
+          batchChangesLimit: {
+            id: 'batchChangesLimit',
+            name: 'Batch Changes Limit',
+          },
+          createAccount: {
+            id: 'createAccount',
+            name: 'Create Account',
+          },
+        };
+        element.updateSection();
+        await element.updateComplete;
+      });
+
+      test('classes are assigned correctly', () => {
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('deleted')
+        );
+        assert.isTrue(
+          queryAndAssert<GrButton>(element, '#editBtn').classList.contains(
+            'global'
+          )
+        );
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.equal(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn'))
+            .display,
+          'none'
+        );
+      });
+    });
+
+    suite('Non-global section', () => {
+      setup(async () => {
+        element.section = {
+          id: 'refs/*' as GitRef,
+          value: {
+            permissions: {
+              read: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {};
+        element.updateSection();
+        await element.updateComplete;
+      });
+
+      test('classes are assigned correctly', async () => {
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('deleted')
+        );
+        assert.isFalse(
+          queryAndAssert<GrButton>(element, '#editBtn').classList.contains(
+            'global'
+          )
+        );
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        await element.updateComplete;
+        assert.notEqual(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn'))
+            .display,
+          'none'
+        );
+      });
+
+      test('add permission', async () => {
+        element.editing = true;
+        queryAndAssert<HTMLSelectElement>(element, '#permissionSelect').value =
+          'label-Code-Review';
+        assert.equal(element.permissions!.length, 1);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 1);
+        queryAndAssert<GrButton>(element, '#addBtn').click();
+        await element.updateComplete;
+
+        // The permission is added to both the permissions array and also
+        // the section's permission object.
+        assert.equal(element.permissions!.length, 2);
+        let permission;
+
+        permission = {
+          id: 'label-Code-Review' as GitRef,
+          value: {
+            added: true,
+            label: 'Code-Review',
+            rules: {},
+          },
+        };
+        assert.equal(element.permissions!.length, 2);
+        assert.deepEqual(element.permissions![1], permission);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 2);
+        assert.deepEqual(
+          element.section!.value.permissions['label-Code-Review'],
+          permission.value
+        );
+
+        queryAndAssert<HTMLSelectElement>(element, '#permissionSelect').value =
+          'abandon';
+        queryAndAssert<GrButton>(element, '#addBtn').click();
+        await element.updateComplete;
+
+        permission = {
+          id: 'abandon' as GitRef,
+          value: {
+            added: true,
+            rules: {},
+          },
+        };
+
+        assert.equal(element.permissions!.length, 3);
+        assert.deepEqual(element.permissions![2], permission);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 3);
+        assert.deepEqual(
+          element.section!.value.permissions['abandon'],
+          permission.value
+        );
+
+        // Unsaved changes are discarded when editing is cancelled.
+        element.editing = false;
+        await element.updateComplete;
+        assert.equal(element.permissions!.length, 1);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 1);
+      });
+
+      test('edit section reference', async () => {
+        element.canUpload = true;
+        element.ownerOf = [];
+        element.section = {
+          id: 'refs/for/bar' as GitRef,
+          value: {permissions: {}},
+        };
+        await element.updateComplete;
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        element.editing = true;
+        await element.updateComplete;
+        assert.isTrue(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        assert.isFalse(element.editingRef);
+        queryAndAssert<GrButton>(element, '#editBtn').click();
+        element.editRefInput().bindValue = 'new/ref';
+        await element.updateComplete;
+        assert.equal(element.section.id, 'new/ref');
+        assert.isTrue(element.editingRef);
+        assert.isTrue(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editingRef')
+        );
+        element.editing = false;
+        await element.updateComplete;
+        assert.isFalse(element.editingRef);
+        assert.equal(element.section.id, 'refs/for/bar');
+      });
+
+      test('handleValueChange', async () => {
+        // For an existing section.
+        const modifiedHandler = sinon.stub();
+        element.section = {
+          id: 'refs/for/bar' as GitRef,
+          value: {permissions: {}},
+        };
+        await element.updateComplete;
+        assert.notOk(element.section.value.updatedId);
+        element.section.id = 'refs/for/baz' as GitRef;
+        await element.updateComplete;
+        element.addEventListener('access-modified', modifiedHandler);
+        assert.isNotOk(element.section.value.modified);
+        element.handleValueChange();
+        assert.equal(element.section.value.updatedId, 'refs/for/baz');
+        assert.isTrue(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 1);
+        element.section.id = 'refs/for/bar' as GitRef;
+        await element.updateComplete;
+        element.handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+
+        // For a new section.
+        element.section.value.added = true;
+        await element.updateComplete;
+        element.handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+        element.section.id = 'refs/for/bar' as GitRef;
+        await element.updateComplete;
+        element.handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+      });
+
+      test('remove section', async () => {
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        await element.updateComplete;
+        assert.isFalse(element.deleted);
+        assert.isNotOk(element.section!.value.deleted);
+        queryAndAssert<GrButton>(element, '#deleteBtn').click();
+        await element.updateComplete;
+        assert.isTrue(element.deleted);
+        assert.isTrue(element.section!.value.deleted);
+        assert.isTrue(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('deleted')
+        );
+        assert.isTrue(element.section!.value.deleted);
+
+        queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
+        await element.updateComplete;
+        assert.isFalse(element.deleted);
+        assert.isNotOk(element.section!.value.deleted);
+
+        queryAndAssert<GrButton>(element, '#deleteBtn').click();
+        await element.updateComplete;
+        assert.isTrue(element.deleted);
+        assert.isTrue(element.section!.value.deleted);
+        element.editing = false;
+        await element.updateComplete;
+        assert.isFalse(element.deleted);
+        assert.isNotOk(element.section!.value.deleted);
+      });
+
+      test('removing an added permission', async () => {
+        element.editing = true;
+        await element.updateComplete;
+        assert.equal(element.permissions!.length, 1);
+        element.shadowRoot!.querySelector('gr-permission')!.dispatchEvent(
+          new CustomEvent('added-permission-removed', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        await element.updateComplete;
+        assert.equal(element.permissions!.length, 0);
+      });
+
+      test('remove an added section', async () => {
+        const removeStub = sinon.stub();
+        element.addEventListener('added-section-removed', removeStub);
+        element.editing = true;
+        element.section!.value.added = true;
+        await element.updateComplete;
+        queryAndAssert<GrButton>(element, '#deleteBtn').click();
+        await element.updateComplete;
+        assert.isTrue(removeStub.called);
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 10ca808..695aa64 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -187,7 +187,7 @@
   private renderAdminNav(item: NavLink) {
     return html`
       <li class="sectionTitle ${this.computeSelectedClass(item.view)}">
-        <a class="title" href="${this.computeLinkURL(item)}" rel="noopener"
+        <a class="title" href=${this.computeLinkURL(item)} rel="noopener"
           >${item.name}</a
         >
       </li>
@@ -198,8 +198,8 @@
 
   private renderAdminNavChild(child: SubsectionInterface) {
     return html`
-      <li class="${this.computeSelectedClass(child.view)}">
-        <a href="${this.computeLinkURL(child)}" rel="noopener">${child.name}</a>
+      <li class=${this.computeSelectedClass(child.view)}>
+        <a href=${this.computeLinkURL(child)} rel="noopener">${child.name}</a>
       </li>
     `;
   }
@@ -209,7 +209,7 @@
 
     return html`
       <!--If a section has a subsection, render that.-->
-      <li class="${this.computeSelectedClass(item.subsection.view)}">
+      <li class=${this.computeSelectedClass(item.subsection.view)}>
         ${this.renderAdminNavSubsectionUrl(item.subsection)}
       </li>
       <!--Loop through the links in the sub-section.-->
@@ -223,7 +223,7 @@
     if (!subsection!.url) return html`${subsection!.name}`;
 
     return html`
-      <a class="title" href="${this.computeLinkURL(subsection)}" rel="noopener">
+      <a class="title" href=${this.computeLinkURL(subsection)} rel="noopener">
         ${subsection!.name}</a
       >
     `;
@@ -237,7 +237,7 @@
           child.detailType
         )}"
       >
-        <a href="${this.computeLinkURL(child)}">${child.name}</a>
+        <a href=${this.computeLinkURL(child)}>${child.name}</a>
       </li>
     `;
   }
@@ -562,32 +562,33 @@
     if (this.needsReload()) await this.reload();
   }
 
-  needsReload() {
-    if (!this.params) return;
+  needsReload(): boolean {
+    if (!this.params) return false;
 
+    let needsReload = false;
     const newRepoName =
       this.params.view === GerritView.REPO ? this.params.repo : undefined;
     if (newRepoName !== this.repoName) {
       this.repoName = newRepoName;
       // Reloads the admin menu.
-      return true;
+      needsReload = true;
     }
     const newGroupId =
       this.params.view === GerritView.GROUP ? this.params.groupId : undefined;
     if (newGroupId !== this.groupId) {
       this.groupId = newGroupId;
       // Reloads the admin menu.
-      return true;
+      needsReload = true;
     }
     if (
       this.breadcrumbParentName &&
       (this.params.view !== GerritView.GROUP || !this.params.groupId) &&
       (this.params.view !== GerritView.REPO || !this.params.repo)
     ) {
-      return true;
+      needsReload = true;
     }
 
-    return false;
+    return needsReload;
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index 5574ad1..0f473c3 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -256,6 +256,30 @@
     assert.equal(reloadStub.callCount, 1);
   });
 
+  test('Nav is reloaded when changing from repo to group', async () => {
+    element.repoName = 'Test Repo' as RepoName;
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        name: 'test-user',
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    await element.reload();
+    await element.updateComplete;
+
+    sinon.stub(element, 'computeGroupName');
+    const reloadStub = sinon.stub(element, 'reload');
+    const groupId = '1' as GroupId;
+    element.params = {groupId, view: GerritView.GROUP};
+    await element.updateComplete;
+
+    assert.equal(reloadStub.callCount, 1);
+    assert.equal(element.groupId, groupId);
+  });
+
   test('Nav is reloaded when group name changes', async () => {
     const newName = 'newName' as GroupName;
     const reloadCalled = mockPromise();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 119b905..9b01735 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -65,7 +65,8 @@
   // private but used in test
   @state() topic?: string;
 
-  @state() private baseChange?: ChangeId;
+  @property({type: String})
+  baseChange?: ChangeId;
 
   @state() private baseCommit?: string;
 
@@ -83,16 +84,15 @@
   constructor() {
     super();
     this.query = (input: string) => this.getRepoBranchesSuggestions(input);
-  }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    if (!this.repoName) return;
-
-    subscribe(this, this.configModel().serverConfig$, config => {
-      this.privateChangesEnabled =
-        config?.change?.disable_private_changes ?? false;
-    });
+    subscribe(
+      this,
+      () => this.configModel().serverConfig$,
+      config => {
+        this.privateChangesEnabled =
+          config?.change?.disable_private_changes ?? false;
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 4c26f89..617ef99 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -36,10 +36,6 @@
 import {fireEvent} from '../../../utils/event-util';
 
 declare global {
-  interface HTMLElementEventMap {
-    'text-changed': CustomEvent<string>;
-    'value-changed': CustomEvent;
-  }
   interface HTMLElementTagNameMap {
     'gr-create-repo-dialog': GrCreateRepoDialog;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index 06dbc5b..7a80396 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-account-label/gr-account-label';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   GroupInfo,
@@ -119,11 +119,14 @@
             ? html`<a href=${this.computeGroupUrl(audit.member)}
                 >${this.getNameForGroup(audit.member)}</a
               >`
-            : html`<gr-account-link .account=${audit.member}></gr-account-link
+            : html`<gr-account-label
+                  .account=${audit.member}
+                  clickable
+                ></gr-account-label
                 >${this.getIdForUser(audit.member)}`}
         </td>
         <td class="by-user">
-          <gr-account-link .account=${audit.user}></gr-account-link>
+          <gr-account-label clickable .account=${audit.user}></gr-account-label>
           ${this.getIdForUser(audit.user)}
         </td>
       </tr>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 8a0068b..ad90759 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
@@ -269,7 +269,7 @@
     return html`
       <tr>
         <td class="nameColumn">
-          <gr-account-link .account=${member}></gr-account-link>
+          <gr-account-label .account=${member} clickable></gr-account-label>
         </td>
         <td>${member.email}</td>
         <td class="deleteColumn">
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index 46a69f4..762bd2d 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -25,6 +25,7 @@
   queryAndAssert,
   stubBaseUrl,
   stubRestApi,
+  waitUntil,
 } from '../../../test/test-utils';
 import {
   AccountId,
@@ -191,8 +192,7 @@
     groupMemberSearchInput.text = memberName;
     groupMemberSearchInput.value = '1234';
 
-    await element.updateComplete;
-    assert.isFalse(button.hasAttribute('disabled'));
+    await waitUntil(() => !button.hasAttribute('disabled'));
 
     return element.handleSavingGroupMember().then(() => {
       assert.isTrue(button.hasAttribute('disabled'));
@@ -218,7 +218,7 @@
 
     const button = queryAndAssert<GrButton>(element, '#saveIncludedGroups');
 
-    assert.isTrue(button.hasAttribute('disabled'));
+    await waitUntil(() => button.hasAttribute('disabled'));
 
     const includedGroupSearchInput = queryAndAssert<GrAutocomplete>(
       element,
@@ -226,8 +226,8 @@
     );
     includedGroupSearchInput.text = includedGroupName;
     includedGroupSearchInput.value = 'testId';
-    await element.updateComplete;
-    assert.isFalse(button.hasAttribute('disabled'));
+
+    await waitUntil(() => !button.hasAttribute('disabled'));
 
     return element.handleSavingIncludedGroups().then(() => {
       assert.isTrue(button.hasAttribute('disabled'));
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 6054d9c..fc53ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -56,10 +56,6 @@
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'text-changed': CustomEvent<string>;
-    'value-changed': CustomEvent;
-  }
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
   }
@@ -136,8 +132,8 @@
   override render() {
     return html`
       <div class="main gr-form-styles read-only">
-        <div id="loading" class="${this.computeLoadingClass()}">Loading...</div>
-        <div id="loadedContent" class="${this.computeLoadingClass()}">
+        <div id="loading" class=${this.computeLoadingClass()}>Loading...</div>
+        <div id="loadedContent" class=${this.computeLoadingClass()}>
           <h1 id="Title" class="heading-1">${this.originalName}</h1>
           <h2 id="configurations" class="heading-2">General</h2>
           <div id="form">
@@ -287,9 +283,9 @@
           <span class="value">
             <gr-select
               id="visibleToAll"
-              .bindValue="${convertToString(
+              .bindValue=${convertToString(
                 Boolean(this.groupConfig?.options?.visible_to_all)
-              )}"
+              )}
               @bind-value-changed=${this.handleOptionsBindValueChanged}
             >
               <select ?disabled=${this.computeGroupDisabled()}>
@@ -493,7 +489,7 @@
     // Because the value for e.detail.value is a string
     // we convert the value to a boolean.
     const value = e.detail.value === 'true' ? true : false;
-    this.groupConfig!.options!.visible_to_all = value;
+    this.groupConfig.options!.visible_to_all = value;
     this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index c2ef76a..a6258b0 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -23,6 +23,7 @@
   mockPromise,
   queryAndAssert,
   stubRestApi,
+  waitUntil,
 } from '../../../test/test-utils';
 import {createGroupInfo} from '../../../test/test-data-generators.js';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
@@ -139,9 +140,8 @@
     queryAndAssert<GrAutocomplete>(element, '#groupNameInput').text =
       groupName2;
 
-    await element.updateComplete;
+    await waitUntil(() => button.hasAttribute('disabled') === false);
 
-    assert.isFalse(button.hasAttribute('disabled'));
     assert.isTrue(
       queryAndAssert<HTMLHeadingElement>(
         element,
@@ -184,8 +184,7 @@
     queryAndAssert<GrAutocomplete>(element, '#groupOwnerInput').text =
       'testId2';
 
-    await element.updateComplete;
-    assert.isFalse(button.disabled);
+    await waitUntil(() => button.disabled === false);
     assert.isTrue(
       queryAndAssert<HTMLHeadingElement>(
         element,
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 4ebdfc0..ff3df99 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -16,28 +16,21 @@
  */
 
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../gr-rule-editor/gr-rule-editor';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-permission_html';
+import {css, html, LitElement, PropertyValues} from 'lit';
 import {
   toSortedPermissionsArray,
   PermissionArrayItem,
   PermissionArray,
+  AccessPermissionId,
 } from '../../../utils/access-util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, property, query, state} from 'lit/decorators';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
   GroupInfo,
-  ProjectAccessGroups,
-  GroupId,
   GitRef,
   RepoName,
 } from '../../../types/common';
@@ -52,10 +45,14 @@
   EditablePermissionRuleInfo,
   EditableProjectAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
-import {PolymerDomRepeatEvent} from '../../../types/types';
 import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
-import {PolymerDomRepeatCustomEvent} from '../../../types/types';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {when} from 'lit/directives/when';
+import {ValueChangedEvent} from '../../../types/events';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -63,12 +60,6 @@
 
 type GroupsWithRulesMap = {[ruleId: string]: boolean};
 
-export interface GrPermission {
-  $: {
-    groupAutocomplete: GrAutocomplete;
-  };
-}
-
 interface ComputedLabelValue {
   value: number;
   text: string;
@@ -95,11 +86,7 @@
  * @event added-permission-removed
  */
 @customElement('gr-permission')
-export class GrPermission extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrPermission extends LitElement {
   @property({type: String})
   repo?: RepoName;
 
@@ -109,7 +96,7 @@
   @property({type: String})
   name?: string;
 
-  @property({type: Object, observer: '_sortPermission', notify: true})
+  @property({type: Object})
   permission?: PermissionArrayItem<EditablePermissionInfo>;
 
   @property({type: Object})
@@ -118,76 +105,243 @@
   @property({type: String})
   section?: GitRef;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
+  @property({type: Boolean})
   editing = false;
 
-  @property({type: Object, computed: '_computeLabel(permission, labels)'})
-  _label?: ComputedLabel;
+  @state()
+  private label?: ComputedLabel;
 
-  @property({type: String})
-  _groupFilter?: string;
+  @state()
+  private groupFilter?: string;
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  @state()
+  private query: AutocompleteQuery;
 
-  @property({type: Array})
-  _rules?: PermissionArray<EditablePermissionRuleInfo>;
+  @state()
+  rules?: PermissionArray<EditablePermissionRuleInfo | undefined>;
 
-  @property({type: Object})
-  _groupsWithRules?: GroupsWithRulesMap;
+  @state()
+  groupsWithRules?: GroupsWithRulesMap;
 
-  @property({type: Boolean})
-  _deleted = false;
+  @state()
+  deleted = false;
 
-  @property({type: Boolean})
-  _originalExclusiveValue?: boolean;
+  @state()
+  originalExclusiveValue?: boolean;
+
+  @query('#groupAutocomplete')
+  private groupAutocomplete!: GrAutocomplete;
 
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = () => this._getGroupSuggestions();
-    this.addEventListener('access-saved', () => this._handleAccessSaved());
+    this.query = () => this.getGroupSuggestions();
+    this.addEventListener('access-saved', () => this.handleAccessSaved());
   }
 
-  override ready() {
-    super.ready();
-    this._setupValues();
+  override connectedCallback() {
+    super.connectedCallback();
+    this.setupValues();
   }
 
-  _setupValues() {
+  override willUpdate(changedProperties: PropertyValues<GrPermission>): void {
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing'));
+    }
+    if (
+      changedProperties.has('permission') ||
+      changedProperties.has('labels')
+    ) {
+      this.label = this.computeLabel();
+    }
+    if (changedProperties.has('permission')) {
+      this.sortPermission(this.permission);
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    paperStyles,
+    formStyles,
+    menuPageStyles,
+    css`
+      :host {
+        display: block;
+        margin-bottom: var(--spacing-m);
+      }
+      .header {
+        align-items: baseline;
+        display: flex;
+        justify-content: space-between;
+        margin: var(--spacing-s) var(--spacing-m);
+      }
+      .rules {
+        background: var(--table-header-background-color);
+        border: 1px solid var(--border-color);
+        border-bottom: 0;
+      }
+      .editing .rules {
+        border-bottom: 1px solid var(--border-color);
+      }
+      .title {
+        margin-bottom: var(--spacing-s);
+      }
+      #addRule,
+      #removeBtn {
+        display: none;
+      }
+      .right {
+        display: flex;
+        align-items: center;
+      }
+      .editing #removeBtn {
+        display: block;
+        margin-left: var(--spacing-xl);
+      }
+      .editing #addRule {
+        display: block;
+        padding: var(--spacing-m);
+      }
+      #deletedContainer,
+      .deleted #mainContainer {
+        display: none;
+      }
+      .deleted #deletedContainer {
+        align-items: baseline;
+        border: 1px solid var(--border-color);
+        display: flex;
+        justify-content: space-between;
+        padding: var(--spacing-m);
+      }
+      #mainContainer {
+        display: block;
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.section || !this.permission) {
+      return;
+    }
+    return html`
+      <section
+        id="permission"
+        class="gr-form-styles ${this.computeSectionClass(
+          this.editing,
+          this.deleted
+        )}"
+      >
+        <div id="mainContainer">
+          <div class="header">
+            <span class="title">${this.name}</span>
+            <div class="right">
+              ${when(
+                !this.permissionIsOwnerOrGlobal(
+                  this.permission.id ?? '',
+                  this.section
+                ),
+                () => html`
+                  <paper-toggle-button
+                    id="exclusiveToggle"
+                    ?checked=${this.permission?.value.exclusive}
+                    ?disabled=${!this.editing}
+                    @change=${this.handleValueChange}
+                    @click=${this.onTapExclusiveToggle}
+                  ></paper-toggle-button
+                  >${this.computeExclusiveLabel(this.permission?.value)}
+                `
+              )}
+              <gr-button
+                link=""
+                id="removeBtn"
+                @click=${this.handleRemovePermission}
+                >Remove</gr-button
+              >
+            </div>
+          </div>
+          <!-- end header -->
+          <div class="rules">
+            ${this.rules?.map(
+              (rule, index) => html`
+                <gr-rule-editor
+                  .hasRange=${this.computeHasRange(this.name)}
+                  .label=${this.label}
+                  .editing=${this.editing}
+                  .groupId=${rule.id}
+                  .groupName=${this.computeGroupName(this.groups, rule.id)}
+                  .permission=${this.permission!.id as AccessPermissionId}
+                  .rule=${rule}
+                  .section=${this.section}
+                  @rule-changed=${(e: CustomEvent) =>
+                    this.handleRuleChanged(e, index)}
+                  @added-rule-removed=${(_: Event) =>
+                    this.handleAddedRuleRemoved(index)}
+                ></gr-rule-editor>
+              `
+            )}
+            <div id="addRule">
+              <gr-autocomplete
+                id="groupAutocomplete"
+                .text=${this.groupFilter ?? ''}
+                @text-changed=${(e: ValueChangedEvent) =>
+                  (this.groupFilter = e.detail.value)}
+                .query=${this.query}
+                placeholder="Add group"
+                @commit=${this.handleAddRuleItem}
+              >
+              </gr-autocomplete>
+            </div>
+            <!-- end addRule -->
+          </div>
+          <!-- end rules -->
+        </div>
+        <!-- end mainContainer -->
+        <div id="deletedContainer">
+          <span>${this.name} was deleted</span>
+          <gr-button link="" id="undoRemoveBtn" @click=${this.handleUndoRemove}
+            >Undo</gr-button
+          >
+        </div>
+        <!-- end deletedContainer -->
+      </section>
+    `;
+  }
+
+  setupValues() {
     if (!this.permission) {
       return;
     }
-    this._originalExclusiveValue = !!this.permission.value.exclusive;
-    flush();
+    this.originalExclusiveValue = !!this.permission.value.exclusive;
+    this.requestUpdate();
   }
 
-  _handleAccessSaved() {
+  private handleAccessSaved() {
     // Set a new 'original' value to keep track of after the value has been
     // saved.
-    this._setupValues();
+    this.setupValues();
   }
 
-  _permissionIsOwnerOrGlobal(permissionId: string, section: string) {
+  private permissionIsOwnerOrGlobal(permissionId: string, section: string) {
     return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
     if (!editingOld) {
       return;
     }
-    if (!this.permission || !this._rules) {
+    if (!this.permission || !this.rules) {
       return;
     }
 
     // Restore original values if no longer editing.
-    if (!editing) {
-      this._deleted = false;
+    if (!this.editing) {
+      this.deleted = false;
       delete this.permission.value.deleted;
-      this._groupFilter = '';
-      this._rules = this._rules.filter(rule => !rule.value.added);
+      this.groupFilter = '';
+      this.rules = this.rules.filter(rule => !rule.value!.added);
+      this.handleRulesChanged();
       for (const key of Object.keys(this.permission.value.rules)) {
         if (this.permission.value.rules[key].added) {
           delete this.permission.value.rules[key];
@@ -195,58 +349,58 @@
       }
 
       // Restore exclusive bit to original.
-      this.set(
-        ['permission', 'value', 'exclusive'],
-        this._originalExclusiveValue
-      );
+      this.permission.value.exclusive = this.originalExclusiveValue;
+      fire(this, 'permission-changed', {value: this.permission});
+      this.requestUpdate();
     }
   }
 
-  _handleAddedRuleRemoved(e: PolymerDomRepeatEvent) {
-    if (!this._rules) {
+  private handleAddedRuleRemoved(index: number) {
+    if (!this.rules) {
       return;
     }
-    const index = e.model.index;
-    this._rules = this._rules
+    this.rules = this.rules
       .slice(0, index)
-      .concat(this._rules.slice(index + 1, this._rules.length));
+      .concat(this.rules.slice(index + 1, this.rules.length));
+    this.handleRulesChanged();
   }
 
-  _handleValueChange() {
+  handleValueChange(e: Event) {
     if (!this.permission) {
       return;
     }
     this.permission.value.modified = true;
+    this.permission.value.exclusive = (e.target as HTMLInputElement).checked;
     // Allows overall access page to know a change has been made.
     fireEvent(this, 'access-modified');
   }
 
-  _handleRemovePermission() {
+  handleRemovePermission() {
     if (!this.permission) {
       return;
     }
     if (this.permission.value.added) {
       fireEvent(this, 'added-permission-removed');
     }
-    this._deleted = true;
+    this.deleted = true;
     this.permission.value.deleted = true;
     fireEvent(this, 'access-modified');
   }
 
-  @observe('_rules.splices')
-  _handleRulesChanged() {
-    if (!this._rules) {
+  private handleRulesChanged() {
+    if (!this.rules) {
       return;
     }
     // Update the groups to exclude in the autocomplete.
-    this._groupsWithRules = this._computeGroupsWithRules(this._rules);
+    this.groupsWithRules = this.computeGroupsWithRules(this.rules);
   }
 
-  _sortPermission(permission: PermissionArrayItem<EditablePermissionInfo>) {
-    this._rules = toSortedPermissionsArray(permission.value.rules);
+  sortPermission(permission?: PermissionArrayItem<EditablePermissionInfo>) {
+    this.rules = toSortedPermissionsArray(permission?.value.rules);
+    this.handleRulesChanged();
   }
 
-  _computeSectionClass(editing: boolean, deleted: boolean) {
+  computeSectionClass(editing: boolean, deleted: boolean) {
     const classList = [];
     if (editing) {
       classList.push('editing');
@@ -257,18 +411,16 @@
     return classList.join(' ');
   }
 
-  _handleUndoRemove() {
+  handleUndoRemove() {
     if (!this.permission) {
       return;
     }
-    this._deleted = false;
+    this.deleted = false;
     delete this.permission.value.deleted;
   }
 
-  _computeLabel(
-    permission?: PermissionArrayItem<EditablePermissionInfo>,
-    labels?: LabelNameToLabelTypeInfoMap
-  ): ComputedLabel | undefined {
+  computeLabel(): ComputedLabel | undefined {
+    const {permission, labels} = this;
     if (
       !labels ||
       !permission ||
@@ -287,11 +439,11 @@
     }
     return {
       name: labelName,
-      values: this._computeLabelValues(labels[labelName].values),
+      values: this.computeLabelValues(labels[labelName].values),
     };
   }
 
-  _computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
+  computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
     const valuesArr: ComputedLabelValue[] = [];
     const keys = Object.keys(values).sort((a, b) => Number(a) - Number(b));
 
@@ -307,8 +459,8 @@
     return valuesArr;
   }
 
-  _computeGroupsWithRules(
-    rules: PermissionArray<EditablePermissionRuleInfo>
+  computeGroupsWithRules(
+    rules: PermissionArray<EditablePermissionRuleInfo | undefined>
   ): GroupsWithRulesMap {
     const groups: GroupsWithRulesMap = {};
     for (const rule of rules) {
@@ -317,16 +469,19 @@
     return groups;
   }
 
-  _computeGroupName(groups: ProjectAccessGroups, groupId: GroupId) {
+  computeGroupName(
+    groups: EditableProjectAccessGroups | undefined,
+    groupId: GitRef
+  ) {
     return groups && groups[groupId] && groups[groupId].name
       ? groups[groupId].name
       : groupId;
   }
 
-  _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
+  getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
       .getSuggestedGroups(
-        this._groupFilter || '',
+        this.groupFilter || '',
         this.repo,
         MAX_AUTOCOMPLETE_RESULTS
       )
@@ -339,7 +494,7 @@
         return groups
           .filter(
             group =>
-              this._groupsWithRules && !this._groupsWithRules[group.value.id]
+              this.groupsWithRules && !this.groupsWithRules[group.value.id]
           )
           .map((group: GroupSuggestion) => {
             const autocompleteSuggestion: AutocompleteSuggestion = {
@@ -355,8 +510,8 @@
    * Handles adding a skeleton item to the dom-repeat.
    * gr-rule-editor handles setting the default values.
    */
-  _handleAddRuleItem(e: AutocompleteCommitEvent) {
-    if (!this.permission || !this._rules) {
+  async handleAddRuleItem(e: AutocompleteCommitEvent) {
+    if (!this.permission || !this.rules) {
       return;
     }
 
@@ -373,33 +528,35 @@
 
     // Purposely don't recompute sorted array so that the newly added rule
     // is the last item of the array.
-    this.push('_rules', {
-      id: groupId,
+    this.rules.push({
+      id: groupId as GitRef,
+      value: undefined,
     });
-
-    // Add the new group name to the groups object so the name renders
-    // correctly.
-    if (this.groups && !this.groups[groupId]) {
-      this.groups[groupId] = {name: this.$.groupAutocomplete.text};
-    }
-
-    // Clear the text of the auto-complete box, so that the user can add the
-    // next group.
-    this.$.groupAutocomplete.text = '';
-
     // Wait for new rule to get value populated via gr-rule-editor, and then
     // add to permission values as well, so that the change gets propagated
     // back to the section. Since the rule is inside a dom-repeat, a flush
     // is needed.
-    flush();
-    const value = this._rules[this._rules.length - 1].value;
-    value.added = true;
-    // See comment above for why we cannot use "this.set(...)" here.
-    this.permission.value.rules[groupId] = value;
+    this.requestUpdate();
+    await this.updateComplete;
+
+    // Add the new group name to the groups object so the name renders
+    // correctly.
+    if (this.groups && !this.groups[groupId]) {
+      this.groups[groupId] = {name: this.groupAutocomplete.text};
+    }
+
+    // Clear the text of the auto-complete box, so that the user can add the
+    // next group.
+    this.groupAutocomplete.text = '';
+
+    const value = this.rules[this.rules.length - 1].value;
+    value!.added = true;
+    this.permission.value.rules[groupId] = value!;
     fireEvent(this, 'access-modified');
+    this.requestUpdate();
   }
 
-  _computeHasRange(name: string) {
+  computeHasRange(name?: string) {
     if (!name) {
       return false;
     }
@@ -407,32 +564,30 @@
     return RANGE_NAMES.includes(name.toUpperCase());
   }
 
-  _computeExclusiveLabel(permission?: EditablePermissionInfo) {
+  private computeExclusiveLabel(permission?: EditablePermissionInfo) {
     return permission?.exclusive ? 'Exclusive' : 'Not Exclusive';
   }
 
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapExclusiveToggle(e: Event) {
+  private onTapExclusiveToggle(e: Event) {
     e.preventDefault();
   }
 
-  _handleRuleChanged(e: PolymerDomRepeatCustomEvent) {
-    if (
-      this._rules === undefined ||
-      (e as CustomEvent).detail.value === undefined
-    )
-      return;
-    const index = Number(e.model.index);
-    if (isNaN(index)) {
-      return;
-    }
-    this.splice('_rules', index, (e as CustomEvent).detail.value);
+  private handleRuleChanged(e: CustomEvent, index: number) {
+    this.rules!.splice(index, e.detail.value);
+    this.handleRulesChanged();
+    this.requestUpdate();
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'permission-changed': ValueChangedEvent<
+      PermissionArrayItem<EditablePermissionInfo>
+    >;
+  }
   interface HTMLElementTagNameMap {
     'gr-permission': GrPermission;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
deleted file mode 100644
index 779b3fa..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-m);
-    }
-    .header {
-      align-items: baseline;
-      display: flex;
-      justify-content: space-between;
-      margin: var(--spacing-s) var(--spacing-m);
-    }
-    .rules {
-      background: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-bottom: 0;
-    }
-    .editing .rules {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .title {
-      margin-bottom: var(--spacing-s);
-    }
-    #addRule,
-    #removeBtn {
-      display: none;
-    }
-    .right {
-      display: flex;
-      align-items: center;
-    }
-    .editing #removeBtn {
-      display: block;
-      margin-left: var(--spacing-xl);
-    }
-    .editing #addRule {
-      display: block;
-      padding: var(--spacing-m);
-    }
-    #deletedContainer,
-    .deleted #mainContainer {
-      display: none;
-    }
-    .deleted #deletedContainer {
-      align-items: baseline;
-      border: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m);
-    }
-    #mainContainer {
-      display: block;
-    }
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <section
-    id="permission"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    <div id="mainContainer">
-      <div class="header">
-        <span class="title">[[name]]</span>
-        <div class="right">
-          <template
-            is="dom-if"
-            if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]"
-          >
-            <paper-toggle-button
-              id="exclusiveToggle"
-              checked="{{permission.value.exclusive}}"
-              on-change="_handleValueChange"
-              disabled$="[[!editing]]"
-              on-click="_onTapExclusiveToggle"
-            ></paper-toggle-button
-            >[[_computeExclusiveLabel(permission.value)]]
-          </template>
-          <gr-button link="" id="removeBtn" on-click="_handleRemovePermission"
-            >Remove</gr-button
-          >
-        </div>
-      </div>
-      <!-- end header -->
-      <div class="rules">
-        <template is="dom-repeat" items="{{_rules}}" as="rule">
-          <gr-rule-editor
-            has-range="[[_computeHasRange(name)]]"
-            label="[[_label]]"
-            editing="[[editing]]"
-            group-id="[[rule.id]]"
-            group-name="[[_computeGroupName(groups, rule.id)]]"
-            permission="[[permission.id]]"
-            rule="[[rule]]"
-            section="[[section]]"
-            on-added-rule-removed="_handleAddedRuleRemoved"
-            on-rule-changed="_handleRuleChanged"
-          ></gr-rule-editor>
-        </template>
-        <div id="addRule">
-          <gr-autocomplete
-            id="groupAutocomplete"
-            text="{{_groupFilter}}"
-            query="[[_query]]"
-            placeholder="Add group"
-            on-commit="_handleAddRuleItem"
-          >
-          </gr-autocomplete>
-        </div>
-        <!-- end addRule -->
-      </div>
-      <!-- end rules -->
-    </div>
-    <!-- end mainContainer -->
-    <div id="deletedContainer">
-      <span>[[name]] was deleted</span>
-      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-        >Undo</gr-button
-      >
-    </div>
-    <!-- end deletedContainer -->
-  </section>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
index fac0729..b77a9ef0 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma';
 import './gr-permission';
 import {GrPermission} from './gr-permission';
-import {stubRestApi} from '../../../test/test-utils';
+import {query, stubRestApi} from '../../../test/test-utils';
 import {GitRef, GroupId, GroupName} from '../../../types/common';
 import {PermissionAction} from '../../../constants/constants';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
@@ -50,7 +50,7 @@
   });
 
   suite('unit tests', () => {
-    test('_sortPermission', () => {
+    test('sortPermission', async () => {
       const permission = {
         id: 'submit' as GitRef,
         value: {
@@ -78,18 +78,19 @@
         },
       ];
 
-      element._sortPermission(permission);
-      assert.deepEqual(element._rules, expectedRules);
+      element.sortPermission(permission);
+      await element.updateComplete;
+      assert.deepEqual(element.rules, expectedRules);
     });
 
-    test('_computeLabel and _computeLabelValues', () => {
+    test('computeLabel and computeLabelValues', async () => {
       const labels = {
         'Code-Review': {
           default_value: 0,
           values: {
             ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
             '+1': 'Looks good to me, but someone else must approve',
             '+2': 'Looks good to me, approved',
           },
@@ -117,8 +118,8 @@
       };
 
       const expectedLabelValues = [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -2, text: 'This shall not be submitted'},
+        {value: -1, text: 'I would prefer this is not submitted as is'},
         {value: 0, text: 'No score'},
         {value: 1, text: 'Looks good to me, but someone else must approve'},
         {value: 2, text: 'Looks good to me, approved'},
@@ -129,15 +130,16 @@
         values: expectedLabelValues,
       };
 
+      element.permission = permission;
+      element.labels = labels;
+      await element.updateComplete;
+
       assert.deepEqual(
-        element._computeLabelValues(labels['Code-Review'].values),
+        element.computeLabelValues(labels['Code-Review'].values),
         expectedLabelValues
       );
 
-      assert.deepEqual(
-        element._computeLabel(permission, labels),
-        expectedLabel
-      );
+      assert.deepEqual(element.computeLabel(), expectedLabel);
 
       permission = {
         id: 'label-reviewDB' as GitRef,
@@ -160,43 +162,46 @@
         },
       };
 
-      assert.isNotOk(element._computeLabel(permission, labels));
+      element.permission = permission;
+      await element.updateComplete;
+
+      assert.isNotOk(element.computeLabel());
     });
 
-    test('_computeSectionClass', () => {
+    test('computeSectionClass', async () => {
       let deleted = true;
       let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+      assert.equal(element.computeSectionClass(editing, deleted), 'deleted');
 
       deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
+      assert.equal(element.computeSectionClass(editing, deleted), '');
 
       editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+      assert.equal(element.computeSectionClass(editing, deleted), 'editing');
 
       deleted = true;
       assert.equal(
-        element._computeSectionClass(editing, deleted),
+        element.computeSectionClass(editing, deleted),
         'editing deleted'
       );
     });
 
-    test('_computeGroupName', () => {
+    test('computeGroupName', async () => {
       const groups = {
         abc123: {id: '1' as GroupId, name: 'test group' as GroupName},
         bcd234: {id: '1' as GroupId},
       };
       assert.equal(
-        element._computeGroupName(groups, 'abc123' as GroupId),
+        element.computeGroupName(groups, 'abc123' as GitRef),
         'test group' as GroupName
       );
       assert.equal(
-        element._computeGroupName(groups, 'bcd234' as GroupId),
+        element.computeGroupName(groups, 'bcd234' as GitRef),
         'bcd234' as GroupName
       );
     });
 
-    test('_computeGroupsWithRules', () => {
+    test('computeGroupsWithRules', async () => {
       const rules = [
         {
           id: '4c97682e6ce6b7247f3381b6f1789356666de7f' as GitRef,
@@ -211,13 +216,14 @@
         '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
         'global:Project-Owners': true,
       };
-      assert.deepEqual(element._computeGroupsWithRules(rules), groupsWithRules);
+      assert.deepEqual(element.computeGroupsWithRules(rules), groupsWithRules);
     });
 
-    test('_getGroupSuggestions without existing rules', async () => {
-      element._groupsWithRules = {};
+    test('getGroupSuggestions without existing rules', async () => {
+      element.groupsWithRules = {};
+      await element.updateComplete;
 
-      const groups = await element._getGroupSuggestions();
+      const groups = await element.getGroupSuggestions();
       assert.deepEqual(groups, [
         {
           name: 'Administrators',
@@ -230,12 +236,13 @@
       ]);
     });
 
-    test('_getGroupSuggestions with existing rules filters them', async () => {
-      element._groupsWithRules = {
+    test('getGroupSuggestions with existing rules filters them', async () => {
+      element.groupsWithRules = {
         '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
       };
+      await element.updateComplete;
 
-      const groups = await element._getGroupSuggestions();
+      const groups = await element.getGroupSuggestions();
       assert.deepEqual(groups, [
         {
           name: 'Anonymous Users',
@@ -244,48 +251,53 @@
       ]);
     });
 
-    test('_handleRemovePermission', () => {
+    test('handleRemovePermission', async () => {
       element.editing = true;
       element.permission = {id: 'test' as GitRef, value: {rules: {}}};
-      element._handleRemovePermission();
-      assert.isTrue(element._deleted);
+      element.handleRemovePermission();
+      await element.updateComplete;
+
+      assert.isTrue(element.deleted);
       assert.isTrue(element.permission.value.deleted);
 
       element.editing = false;
-      assert.isFalse(element._deleted);
+      await element.updateComplete;
+      assert.isFalse(element.deleted);
       assert.isNotOk(element.permission.value.deleted);
     });
 
-    test('_handleUndoRemove', () => {
+    test('handleUndoRemove', async () => {
       element.permission = {
         id: 'test' as GitRef,
         value: {deleted: true, rules: {}},
       };
-      element._handleUndoRemove();
-      assert.isFalse(element._deleted);
+      element.handleUndoRemove();
+      await element.updateComplete;
+
+      assert.isFalse(element.deleted);
       assert.isNotOk(element.permission.value.deleted);
     });
 
-    test('_computeHasRange', () => {
-      assert.isTrue(element._computeHasRange('Query Limit'));
+    test('computeHasRange', async () => {
+      assert.isTrue(element.computeHasRange('Query Limit'));
 
-      assert.isTrue(element._computeHasRange('Batch Changes Limit'));
+      assert.isTrue(element.computeHasRange('Batch Changes Limit'));
 
-      assert.isFalse(element._computeHasRange('test'));
+      assert.isFalse(element.computeHasRange('test'));
     });
   });
 
   suite('interactions', () => {
-    setup(() => {
-      sinon.spy(element, '_computeLabel');
+    setup(async () => {
+      sinon.spy(element, 'computeLabel');
       element.name = 'Priority';
       element.section = 'refs/*' as GitRef;
       element.labels = {
         'Code-Review': {
           values: {
             ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
             '+1': 'Looks good to me, but someone else must approve',
             '+2': 'Looks good to me, approved',
           },
@@ -312,14 +324,17 @@
           },
         },
       };
-      element._setupValues();
+      element.setupValues();
+      await element.updateComplete;
       flush();
     });
 
-    test('adding a rule', () => {
+    test('adding a rule', async () => {
       element.name = 'Priority';
       element.section = 'refs/*' as GitRef;
       element.groups = {};
+      await element.updateComplete;
+
       queryAndAssert<GrAutocomplete>(element, '#groupAutocomplete').text =
         'ldap/tests te.st';
       const e = {
@@ -328,17 +343,16 @@
         },
       } as CustomEvent<AutocompleteCommitEventDetail>;
       element.editing = true;
-      assert.equal(element._rules!.length, 2);
-      assert.equal(Object.keys(element._groupsWithRules!).length, 2);
-      element._handleAddRuleItem(e);
-      flush();
+      assert.equal(element.rules!.length, 2);
+      assert.equal(Object.keys(element.groupsWithRules!).length, 2);
+      await element.handleAddRuleItem(e);
       assert.deepEqual(element.groups, {
         'ldap:CN=test te.st': {
           name: 'ldap/tests te.st',
         },
       });
-      assert.equal(element._rules!.length, 3);
-      assert.equal(Object.keys(element._groupsWithRules!).length, 3);
+      assert.equal(element.rules!.length, 3);
+      assert.equal(Object.keys(element.groupsWithRules!).length, 3);
       assert.deepEqual(element.permission!.value.rules['ldap:CN=test te.st'], {
         action: PermissionAction.ALLOW,
         min: -2,
@@ -351,7 +365,8 @@
       );
       // New rule should be removed if cancel from editing.
       element.editing = false;
-      assert.equal(element._rules!.length, 2);
+      await element.updateComplete;
+      assert.equal(element.rules!.length, 2);
       assert.equal(Object.keys(element.permission!.value.rules).length, 2);
     });
 
@@ -359,9 +374,10 @@
       element.name = 'Priority';
       element.section = 'refs/*' as GitRef;
       element.groups = {};
+      await element.updateComplete;
       queryAndAssert<GrAutocomplete>(element, '#groupAutocomplete').text =
         'new group name';
-      assert.equal(element._rules!.length, 2);
+      assert.equal(element.rules!.length, 2);
       queryAndAssert<GrRuleEditor>(element, 'gr-rule-editor').dispatchEvent(
         new CustomEvent('added-rule-removed', {
           composed: true,
@@ -369,24 +385,27 @@
         })
       );
       await flush();
-      assert.equal(element._rules!.length, 1);
+      assert.equal(element.rules!.length, 1);
     });
 
-    test('removing an added permission', () => {
+    test('removing an added permission', async () => {
       const removeStub = sinon.stub();
       element.addEventListener('added-permission-removed', removeStub);
       element.editing = true;
       element.name = 'Priority';
       element.section = 'refs/*' as GitRef;
       element.permission!.value.added = true;
+      await element.updateComplete;
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      await element.updateComplete;
       assert.isTrue(removeStub.called);
     });
 
-    test('removing the permission', () => {
+    test('removing the permission', async () => {
       element.editing = true;
       element.name = 'Priority';
       element.section = 'refs/*' as GitRef;
+      await element.updateComplete;
 
       const removeStub = sinon.stub();
       element.addEventListener('added-permission-removed', removeStub);
@@ -394,72 +413,72 @@
       assert.isFalse(
         queryAndAssert(element, '#permission').classList.contains('deleted')
       );
-      assert.isFalse(element._deleted);
+      assert.isFalse(element.deleted);
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      await element.updateComplete;
       assert.isTrue(
         queryAndAssert(element, '#permission').classList.contains('deleted')
       );
-      assert.isTrue(element._deleted);
+      assert.isTrue(element.deleted);
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      await element.updateComplete;
       assert.isFalse(
         queryAndAssert(element, '#permission').classList.contains('deleted')
       );
-      assert.isFalse(element._deleted);
+      assert.isFalse(element.deleted);
       assert.isFalse(removeStub.called);
     });
 
-    test('modify a permission', () => {
+    test('modify a permission', async () => {
       element.editing = true;
       element.name = 'Priority';
       element.section = 'refs/*' as GitRef;
+      await element.updateComplete;
 
-      assert.isFalse(element._originalExclusiveValue);
+      assert.isFalse(element.originalExclusiveValue);
       assert.isNotOk(element.permission!.value.modified);
-      queryAndAssert(element, '#exclusiveToggle');
       MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
-      flush();
+      await element.updateComplete;
       assert.isTrue(element.permission!.value.exclusive);
       assert.isTrue(element.permission!.value.modified);
-      assert.isFalse(element._originalExclusiveValue);
+      assert.isFalse(element.originalExclusiveValue);
       element.editing = false;
+      await element.updateComplete;
       assert.isFalse(element.permission!.value.exclusive);
     });
 
-    test('_handleValueChange', () => {
+    test('modifying emits access-modified event', async () => {
       const modifiedHandler = sinon.stub();
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
       element.permission = {id: '0' as GitRef, value: {rules: {}}};
       element.addEventListener('access-modified', modifiedHandler);
+      await element.updateComplete;
       assert.isNotOk(element.permission.value.modified);
-      element._handleValueChange();
+      MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
+      await element.updateComplete;
       assert.isTrue(element.permission.value.modified);
       assert.isTrue(modifiedHandler.called);
     });
 
-    test('Exclusive hidden for owner permission', () => {
+    test('Exclusive hidden for owner permission', async () => {
       queryAndAssert(element, '#exclusiveToggle');
-      assert.equal(
-        getComputedStyle(queryAndAssert(element, '#exclusiveToggle')).display,
-        'flex'
-      );
-      element.set(['permission', 'id'], 'owner');
-      flush();
-      assert.equal(
-        getComputedStyle(queryAndAssert(element, '#exclusiveToggle')).display,
-        'none'
-      );
+
+      element.permission!.id = 'owner' as GitRef;
+      element.requestUpdate();
+      await element.updateComplete;
+
+      assert.notOk(query(element, '#exclusiveToggle'));
     });
 
-    test('Exclusive hidden for any global permissions', () => {
-      assert.equal(
-        getComputedStyle(queryAndAssert(element, '#exclusiveToggle')).display,
-        'flex'
-      );
+    test('Exclusive hidden for any global permissions', async () => {
+      queryAndAssert(element, '#exclusiveToggle');
+
       element.section = 'GLOBAL_CAPABILITIES' as GitRef;
-      flush();
-      assert.equal(
-        getComputedStyle(queryAndAssert(element, '#exclusiveToggle')).display,
-        'none'
-      );
+      await element.updateComplete;
+
+      assert.notOk(query(element, '#exclusiveToggle'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 55f7567..2033180 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -14,19 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-plugin-config-array-editor_html';
-import {property, customElement} from '@polymer/decorators';
 import {
   PluginConfigOptionsChangedEventDetail,
   ArrayPluginOption,
 } from '../gr-repo-plugin-config/gr-repo-plugin-config-types';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -35,61 +35,153 @@
 }
 
 @customElement('gr-plugin-config-array-editor')
-export class GrPluginConfigArrayEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrPluginConfigArrayEditor extends LitElement {
   /**
    * Fired when the plugin config option changes.
    *
    * @event plugin-config-option-changed
    */
 
-  @property({type: String})
-  _newValue = '';
+  // private but used in test
+  @state() newValue = '';
 
   // This property is never null, since this component in only about operations
   // on pluginOption.
   @property({type: Object})
   pluginOption!: ArrayPluginOption;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  _handleAddTap(e: MouseEvent) {
-    e.preventDefault();
-    this._handleAdd();
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        .wrapper {
+          width: 30em;
+        }
+        .existingItems {
+          background: var(--table-header-background-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+        }
+        gr-button {
+          float: right;
+          margin-left: var(--spacing-m);
+          width: 4.5em;
+        }
+        .row {
+          align-items: center;
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m) 0;
+          width: 100%;
+        }
+        .existingItems .row {
+          padding: var(--spacing-m);
+        }
+        .existingItems .row:not(:first-of-type) {
+          border-top: 1px solid var(--border-color);
+        }
+        input {
+          flex-grow: 1;
+        }
+        .hide {
+          display: none;
+        }
+        .placeholder {
+          color: var(--deemphasized-text-color);
+          padding-top: var(--spacing-m);
+        }
+      `,
+    ];
   }
 
-  _handleInputKeydown(e: KeyboardEvent) {
+  override render() {
+    return html`
+      <div class="wrapper gr-form-styles">
+        ${this.renderPluginOptions()}
+        <div class="row ${this.disabled ? 'hide' : ''}">
+          <iron-input
+            .bindValue=${this.newValue}
+            @bind-value-changed=${this.handleBindValueChangedNewValue}
+          >
+            <input
+              id="input"
+              @keydown=${this.handleInputKeydown}
+              ?disabled=${this.disabled}
+            />
+          </iron-input>
+          <gr-button
+            id="addButton"
+            ?disabled=${!this.newValue.length}
+            link
+            @click=${this.handleAddTap}
+            >Add</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderPluginOptions() {
+    if (!this.pluginOption?.info?.values?.length) {
+      return html`<div class="row placeholder">None configured.</div>`;
+    }
+
+    return html`
+      <div class="existingItems">
+        ${this.pluginOption.info.values.map(item =>
+          this.renderPluginOptionValue(item)
+        )}
+      </div>
+    `;
+  }
+
+  private renderPluginOptionValue(item: string) {
+    return html`
+      <div class="row">
+        <span>${item}</span>
+        <gr-button
+          link
+          ?disabled=${this.disabled}
+          @click=${() => this.handleDelete(item)}
+          >Delete</gr-button
+        >
+      </div>
+    `;
+  }
+
+  private handleAddTap(e: MouseEvent) {
+    e.preventDefault();
+    this.handleAdd();
+  }
+
+  private handleInputKeydown(e: KeyboardEvent) {
     // Enter.
     if (e.keyCode === 13) {
       e.preventDefault();
-      this._handleAdd();
+      this.handleAdd();
     }
   }
 
-  _handleAdd() {
-    if (!this._newValue.length) {
+  private handleAdd() {
+    if (!this.newValue.length) {
       return;
     }
-    this._dispatchChanged(
-      this.pluginOption.info.values.concat([this._newValue])
-    );
-    this._newValue = '';
+    this.dispatchChanged(this.pluginOption.info.values.concat([this.newValue]));
+    this.newValue = '';
   }
 
-  _handleDelete(e: MouseEvent) {
-    const value = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
-      'item'
-    ];
-    this._dispatchChanged(
+  private handleDelete(value: string) {
+    this.dispatchChanged(
       this.pluginOption.info.values.filter(str => str !== value)
     );
   }
 
-  _dispatchChanged(values: string[]) {
+  // private but used in test
+  dispatchChanged(values: string[]) {
     const {_key, info} = this.pluginOption;
     const detail: PluginConfigOptionsChangedEventDetail = {
       _key,
@@ -101,7 +193,7 @@
     );
   }
 
-  _computeShowInputRow(disabled: boolean) {
-    return disabled ? 'hide' : '';
+  private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
+    this.newValue = e.detail.value;
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
deleted file mode 100644
index 7709198..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .wrapper {
-      width: 30em;
-    }
-    .existingItems {
-      background: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-    }
-    gr-button {
-      float: right;
-      margin-left: var(--spacing-m);
-      width: 4.5em;
-    }
-    .row {
-      align-items: center;
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .existingItems .row {
-      padding: var(--spacing-m);
-    }
-    .existingItems .row:not(:first-of-type) {
-      border-top: 1px solid var(--border-color);
-    }
-    input {
-      flex-grow: 1;
-    }
-    .hide {
-      display: none;
-    }
-    .placeholder {
-      color: var(--deemphasized-text-color);
-      padding-top: var(--spacing-m);
-    }
-  </style>
-  <div class="wrapper gr-form-styles">
-    <template is="dom-if" if="[[pluginOption.info.values.length]]">
-      <div class="existingItems">
-        <template is="dom-repeat" items="[[pluginOption.info.values]]">
-          <div class="row">
-            <span>[[item]]</span>
-            <gr-button
-              link=""
-              disabled$="[[disabled]]"
-              data-item$="[[item]]"
-              on-click="_handleDelete"
-              >Delete</gr-button
-            >
-          </div>
-        </template>
-      </div>
-    </template>
-    <template is="dom-if" if="[[!pluginOption.info.values.length]]">
-      <div class="row placeholder">None configured.</div>
-    </template>
-    <div class$="row [[_computeShowInputRow(disabled)]]">
-      <iron-input on-keydown="_handleInputKeydown" bind-value="{{_newValue}}">
-        <input
-          is="iron-input"
-          id="input"
-          on-keydown="_handleInputKeydown"
-          bind-value="{{_newValue}}"
-          disabled$="[[disabled]]"
-        />
-      </iron-input>
-      <gr-button
-        id="addButton"
-        disabled$="[[!_newValue.length]]"
-        link=""
-        on-click="_handleAddTap"
-        >Add</gr-button
-      >
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
deleted file mode 100644
index 326ff44..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-plugin-config-array-editor.js';
-
-const basicFixture = fixtureFromElement('gr-plugin-config-array-editor');
-
-suite('gr-plugin-config-array-editor tests', () => {
-  let element;
-
-  let dispatchStub;
-
-  const getAll = str => element.root.querySelectorAll(str);
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.pluginOption = {
-      _key: 'test-key',
-      info: {
-        values: [],
-      },
-    };
-  });
-
-  test('_computeShowInputRow', () => {
-    assert.equal(element._computeShowInputRow(true), 'hide');
-    assert.equal(element._computeShowInputRow(false), '');
-  });
-
-  suite('adding', () => {
-    setup(() => {
-      dispatchStub = sinon.stub(element, '_dispatchChanged');
-    });
-
-    test('with enter', () => {
-      element._newValue = '';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      assert.isFalse(element.$.input.hasAttribute('disabled'));
-      flush();
-
-      assert.isFalse(dispatchStub.called);
-      element._newValue = 'test';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      assert.isFalse(element.$.input.hasAttribute('disabled'));
-      flush();
-
-      assert.isTrue(dispatchStub.called);
-      assert.equal(dispatchStub.lastCall.args[0], 'test');
-      assert.equal(element._newValue, '');
-    });
-
-    test('with add btn', () => {
-      element._newValue = '';
-      MockInteractions.tap(element.$.addButton);
-      flush();
-
-      assert.isFalse(dispatchStub.called);
-      element._newValue = 'test';
-      MockInteractions.tap(element.$.addButton);
-      flush();
-
-      assert.isTrue(dispatchStub.called);
-      assert.equal(dispatchStub.lastCall.args[0], 'test');
-      assert.equal(element._newValue, '');
-    });
-  });
-
-  test('deleting', () => {
-    dispatchStub = sinon.stub(element, '_dispatchChanged');
-    element.pluginOption = {info: {values: ['test', 'test2']}};
-    element.disabled = true;
-    flush();
-
-    const rows = getAll('.existingItems .row');
-    assert.equal(rows.length, 2);
-    const button = rows[0].querySelector('gr-button');
-
-    MockInteractions.tap(button);
-    flush();
-
-    assert.isFalse(dispatchStub.called);
-    element.disabled = false;
-    element.notifyPath('pluginOption.info.editable');
-    flush();
-
-    MockInteractions.tap(button);
-    flush();
-
-    assert.isTrue(dispatchStub.called);
-    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
-  });
-
-  test('_dispatchChanged', () => {
-    const eventStub = sinon.stub(element, 'dispatchEvent');
-    element._dispatchChanged(['new-test-value']);
-
-    assert.isTrue(eventStub.called);
-    const {detail} = eventStub.lastCall.args[0];
-    assert.equal(detail._key, 'test-key');
-    assert.deepEqual(detail.info, {values: ['new-test-value']});
-    assert.equal(detail.notifyPath, 'test-key.values');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
new file mode 100644
index 0000000..5de1f1e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
@@ -0,0 +1,142 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {ConfigParameterInfoType} from '../../../constants/constants.js';
+import '../../../test/common-test-setup-karma';
+import './gr-plugin-config-array-editor';
+import {GrPluginConfigArrayEditor} from './gr-plugin-config-array-editor';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAll, queryAndAssert} from '../../../test/test-utils.js';
+import {GrButton} from '../../shared/gr-button/gr-button.js';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+suite('gr-plugin-config-array-editor tests', () => {
+  let element: GrPluginConfigArrayEditor;
+
+  let dispatchStub: sinon.SinonStub;
+
+  setup(async () => {
+    element = await fixture<GrPluginConfigArrayEditor>(html`
+      <gr-plugin-config-array-editor></gr-plugin-config-array-editor>
+    `);
+    element.pluginOption = {
+      _key: 'test-key',
+      info: {
+        type: ConfigParameterInfoType.ARRAY,
+        values: [],
+      },
+    };
+  });
+
+  suite('adding', () => {
+    setup(() => {
+      dispatchStub = sinon.stub(element, 'dispatchChanged');
+    });
+
+    test('with enter', async () => {
+      element.newValue = '';
+      await element.updateComplete;
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert<HTMLInputElement>(element, '#input'),
+        13
+      ); // Enter
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
+          'disabled'
+        )
+      );
+      await element.updateComplete;
+
+      assert.isFalse(dispatchStub.called);
+      element.newValue = 'test';
+      await element.updateComplete;
+
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert<HTMLInputElement>(element, '#input'),
+        13
+      ); // Enter
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
+          'disabled'
+        )
+      );
+      await element.updateComplete;
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element.newValue, '');
+    });
+
+    test('with add btn', async () => {
+      element.newValue = '';
+      queryAndAssert<GrButton>(element, '#addButton').click();
+      await element.updateComplete;
+
+      assert.isFalse(dispatchStub.called);
+
+      element.newValue = 'test';
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#addButton').click();
+      await element.updateComplete;
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element.newValue, '');
+    });
+  });
+
+  test('deleting', async () => {
+    dispatchStub = sinon.stub(element, 'dispatchChanged');
+    element.pluginOption = {
+      _key: '',
+      info: {type: ConfigParameterInfoType.ARRAY, values: ['test', 'test2']},
+    };
+    element.disabled = true;
+    await element.updateComplete;
+
+    const rows = queryAll(element, '.existingItems .row');
+    assert.equal(rows.length, 2);
+    const button = queryAndAssert<GrButton>(rows[0], 'gr-button');
+
+    MockInteractions.tap(button);
+    await element.updateComplete;
+
+    assert.isFalse(dispatchStub.called);
+    element.disabled = false;
+    await element.updateComplete;
+
+    button.click();
+    await element.updateComplete;
+
+    assert.isTrue(dispatchStub.called);
+    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+  });
+
+  test('dispatchChanged', () => {
+    const eventStub = sinon.stub(element, 'dispatchEvent');
+    element.dispatchChanged(['new-test-value']);
+
+    assert.isTrue(eventStub.called);
+    const {detail} = eventStub.lastCall.args[0] as CustomEvent;
+    assert.equal(detail._key, 'test-key');
+    assert.deepEqual(detail.info, {type: 'ARRAY', values: ['new-test-value']});
+    assert.equal(detail.notifyPath, 'test-key.values');
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index f8b36c4..de61d2d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -14,18 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
+
 import '../gr-access-section/gr-access-section';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-access_html';
 import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
-import {customElement, property} from '@polymer/decorators';
 import {
   RepoName,
   ProjectInfo,
@@ -51,106 +44,293 @@
 import {firePageError, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {ifDefined} from 'lit/directives/if-defined';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
 const MAX_AUTOCOMPLETE_RESULTS = 50;
 
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-access': GrRepoAccess;
+  }
+}
+
 /**
  * Fired when save is a no-op
  *
  * @event show-alert
  */
 @customElement('gr-repo-access')
-export class GrRepoAccess extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoAccess extends LitElement {
+  @query('gr-access-section:last-of-type') accessSection?: GrAccessSection;
 
-  @property({type: String, observer: '_repoChanged'})
+  @property({type: String})
   repo?: RepoName;
 
   @property({type: String})
   path?: string;
 
-  @property({type: Boolean})
-  _canUpload?: boolean = false; // restAPI can return undefined
+  // private but used in test
+  @state() canUpload?: boolean = false; // restAPI can return undefined
 
-  @property({type: String})
-  _inheritFromFilter?: RepoName;
+  // private but used in test
+  @state() inheritFromFilter?: RepoName;
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  // private but used in test
+  @state() ownerOf?: GitRef[];
 
-  @property({type: Array})
-  _ownerOf?: GitRef[];
+  // private but used in test
+  @state() capabilities?: CapabilityInfoMap;
 
-  @property({type: Object})
-  _capabilities?: CapabilityInfoMap;
+  // private but used in test
+  @state() groups?: ProjectAccessGroups;
 
-  @property({type: Object})
-  _groups?: ProjectAccessGroups;
+  // private but used in test
+  @state() inheritsFrom?: ProjectInfo;
 
-  @property({type: Object})
-  _inheritsFrom?: ProjectInfo;
+  // private but used in test
+  @state() labels?: LabelNameToLabelTypeInfoMap;
 
-  @property({type: Object})
-  _labels?: LabelNameToLabelTypeInfoMap;
+  // private but used in test
+  @state() local?: EditableLocalAccessSectionInfo;
 
-  @property({type: Object})
-  _local?: EditableLocalAccessSectionInfo;
+  // private but used in test
+  @state() editing = false;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
-  _editing = false;
+  // private but used in test
+  @state() modified = false;
 
-  @property({type: Boolean})
-  _modified = false;
+  // private but used in test
+  @state() sections?: PermissionAccessSection[];
 
-  @property({type: Array})
-  _sections?: PermissionAccessSection[];
+  @state() private weblinks?: WebLinkInfo[];
 
-  @property({type: Array})
-  _weblinks?: WebLinkInfo[];
-
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
   // private but used in the tests
   originalInheritsFrom?: ProjectInfo;
 
+  private readonly query: AutocompleteQuery;
+
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = () => this._getInheritFromSuggestions();
+    this.query = () => this.getInheritFromSuggestions();
     this.addEventListener('access-modified', () =>
       this._handleAccessModified()
     );
   }
 
+  static override get styles() {
+    return [
+      fontStyles,
+      menuPageStyles,
+      subpageStyles,
+      sharedStyles,
+      css`
+        gr-button,
+        #inheritsFrom,
+        #editInheritFromInput,
+        .editing #inheritFromName,
+        .weblinks,
+        .editing .invisible {
+          display: none;
+        }
+        #inheritsFrom.show {
+          display: flex;
+          min-height: 2em;
+          align-items: center;
+        }
+        .weblink {
+          margin-right: var(--spacing-xs);
+        }
+        gr-access-section {
+          margin-top: var(--spacing-l);
+        }
+        .weblinks.show,
+        .referenceContainer {
+          display: block;
+        }
+        .rightsText {
+          margin-right: var(--spacing-s);
+        }
+
+        .editing gr-button,
+        .admin #editBtn {
+          display: inline-block;
+          margin: var(--spacing-l) 0;
+        }
+        .editing #editInheritFromInput {
+          display: inline-block;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="main ${this.computeMainClass()}">
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          <h3
+            id="inheritsFrom"
+            class="heading-3 ${this.editing || this.inheritsFrom?.id?.length
+              ? 'show'
+              : ''}"
+          >
+            <span class="rightsText">Rights Inherit From</span>
+            <a
+              id="inheritFromName"
+              href=${this.computeParentHref()}
+              rel="noopener"
+            >
+              ${this.inheritsFrom?.name}</a
+            >
+            <gr-autocomplete
+              id="editInheritFromInput"
+              .text=${this.inheritFromFilter}
+              .query=${this.query}
+              @commit=${(e: ValueChangedEvent) => {
+                this.handleUpdateInheritFrom(e);
+              }}
+              @bind-value-changed=${(e: ValueChangedEvent) => {
+                this.handleUpdateInheritFrom(e);
+              }}
+              @text-changed=${(e: ValueChangedEvent) => {
+                this.handleEditInheritFromTextChanged(e);
+              }}
+            ></gr-autocomplete>
+          </h3>
+          <div class="weblinks ${this.weblinks?.length ? 'show' : ''}">
+            History:
+            ${this.weblinks?.map(webLink => this.renderWebLinks(webLink))}
+          </div>
+          ${this.sections?.map((section, index) =>
+            this.renderPermissionSections(section, index)
+          )}
+          <div class="referenceContainer">
+            <gr-button
+              id="addReferenceBtn"
+              @click=${() => this.handleCreateSection()}
+              >Add Reference</gr-button
+            >
+          </div>
+          <div>
+            <gr-button
+              id="editBtn"
+              @click=${() => {
+                this.handleEdit();
+              }}
+              >${this.editing ? 'Cancel' : 'Edit'}</gr-button
+            >
+            <gr-button
+              id="saveBtn"
+              class=${this.ownerOf && this.ownerOf.length === 0
+                ? 'invisible'
+                : ''}
+              primary
+              ?disabled=${!this.modified}
+              @click=${this.handleSave}
+              >Save</gr-button
+            >
+            <gr-button
+              id="saveReviewBtn"
+              class=${!this.canUpload ? 'invisible' : ''}
+              primary
+              ?disabled=${!this.modified}
+              @click=${this.handleSaveForReview}
+              >Save for review</gr-button
+            >
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderWebLinks(webLink: WebLinkInfo) {
+    return html`
+      <a
+        class="weblink"
+        href=${webLink.url}
+        rel="noopener"
+        target=${ifDefined(webLink.target)}
+      >
+        ${webLink.name}
+      </a>
+    `;
+  }
+
+  private renderPermissionSections(
+    section: PermissionAccessSection,
+    index: number
+  ) {
+    return html`
+      <gr-access-section
+        .capabilities=${this.capabilities}
+        .section=${section}
+        .labels=${this.labels}
+        .canUpload=${this.canUpload}
+        .editing=${this.editing}
+        .ownerOf=${this.ownerOf}
+        .groups=${this.groups}
+        .repo=${this.repo}
+        @added-section-removed=${() => {
+          this.handleAddedSectionRemoved(index);
+        }}
+        @section-changed=${(e: ValueChangedEvent<PermissionAccessSection>) => {
+          this.handleAccessSectionChanged(e, index);
+        }}
+      ></gr-access-section>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this._repoChanged(this.repo);
+    }
+
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing') as boolean);
+      this.requestUpdate();
+    }
+  }
+
   _handleAccessModified() {
-    this._modified = true;
+    this.modified = true;
   }
 
   _repoChanged(repo?: RepoName) {
-    this._loading = true;
+    this.loading = true;
 
     if (!repo) {
       return Promise.resolve();
     }
 
-    return this._reload(repo);
+    return this.reload(repo);
   }
 
-  _reload(repo: RepoName) {
+  private reload(repo: RepoName) {
     const errFn = (response?: Response | null) => {
       firePageError(response);
     };
 
-    this._editing = false;
+    this.editing = false;
 
     // Always reset sections when a project changes.
-    this._sections = [];
+    this.sections = [];
     const sectionsPromises = this.restApiService
       .getRepoAccessRights(repo, errFn)
       .then(res => {
@@ -162,26 +342,26 @@
         // the ones data bound to gr-autocomplete, so the original value
         // can be restored if the user cancels.
         if (res.inherits_from) {
-          this._inheritsFrom = {...res.inherits_from};
+          this.inheritsFrom = {...res.inherits_from};
           this.originalInheritsFrom = {...res.inherits_from};
         } else {
-          this._inheritsFrom = undefined;
+          this.inheritsFrom = undefined;
           this.originalInheritsFrom = undefined;
         }
         // Initialize the filter value so when the user clicks edit, the
         // current value appears. If there is no parent repo, it is
         // initialized as an empty string.
-        this._inheritFromFilter = res.inherits_from
+        this.inheritFromFilter = res.inherits_from
           ? res.inherits_from.name
           : ('' as RepoName);
         // 'as EditableLocalAccessSectionInfo' is required because res.local
         // type doesn't have index signature
-        this._local = res.local as EditableLocalAccessSectionInfo;
-        this._groups = res.groups;
-        this._weblinks = res.config_web_links || [];
-        this._canUpload = res.can_upload;
-        this._ownerOf = res.owner_of || [];
-        return toSortedPermissionsArray(this._local);
+        this.local = res.local as EditableLocalAccessSectionInfo;
+        this.groups = res.groups;
+        this.weblinks = res.config_web_links || [];
+        this.canUpload = res.can_upload;
+        this.ownerOf = res.owner_of || [];
+        return toSortedPermissionsArray(this.local);
       });
 
     const capabilitiesPromises = this.restApiService
@@ -209,25 +389,26 @@
       capabilitiesPromises,
       labelsPromises,
     ]).then(([sections, capabilities, labels]) => {
-      this._capabilities = capabilities;
-      this._labels = labels;
-      this._sections = sections;
-      this._loading = false;
+      this.capabilities = capabilities;
+      this.labels = labels;
+      this.sections = sections;
+      this.loading = false;
     });
   }
 
-  _handleUpdateInheritFrom(e: CustomEvent<{value: string}>) {
-    this._inheritsFrom = {
-      ...(this._inheritsFrom ?? {}),
+  // private but used in test
+  handleUpdateInheritFrom(e: ValueChangedEvent) {
+    this.inheritsFrom = {
+      ...(this.inheritsFrom ?? {}),
       id: e.detail.value as UrlEncodedRepoName,
-      name: this._inheritFromFilter,
+      name: this.inheritFromFilter,
     };
     this._handleAccessModified();
   }
 
-  _getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
+  private getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
+      .getRepos(this.inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
       .then(response => {
         const projects: AutocompleteSuggestion[] = [];
         if (!response) {
@@ -243,67 +424,47 @@
       });
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
+  private handleEdit() {
+    this.editing = !this.editing;
   }
 
-  _handleEdit() {
-    this._editing = !this._editing;
-  }
-
-  _editOrCancel(editing: boolean) {
-    return editing ? 'Cancel' : 'Edit';
-  }
-
-  _computeWebLinkClass(weblinks?: string[]) {
-    return weblinks && weblinks.length ? 'show' : '';
-  }
-
-  _computeShowInherit(inheritsFrom?: ProjectInfo) {
-    return this._editing || inheritsFrom?.id?.length ? 'show' : '';
-  }
-
-  // TODO(TS): Unclear what is model here, provide a better explanation
-  _handleAddedSectionRemoved(e: CustomEvent & {model: {index: string}}) {
-    if (!this._sections) {
-      return;
-    }
-    const index = Number(e.model.index);
-    if (isNaN(index)) {
-      return;
-    }
-    this._sections = this._sections
+  private handleAddedSectionRemoved(index: number) {
+    if (!this.sections) return;
+    this.sections = this.sections
       .slice(0, index)
-      .concat(this._sections.slice(index + 1, this._sections.length));
+      .concat(this.sections.slice(index + 1, this.sections.length));
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
-    if (!editingOld || editing) {
+    if (!editingOld || this.editing) {
       return;
     }
     // Remove any unsaved but added refs.
-    if (this._sections) {
-      this._sections = this._sections.filter(p => !p.value.added);
+    if (this.sections) {
+      this.sections = this.sections.filter(p => !p.value.added);
     }
     // Restore inheritFrom.
-    if (this._inheritsFrom) {
-      this._inheritsFrom = this.originalInheritsFrom
+    if (this.inheritsFrom) {
+      this.inheritsFrom = this.originalInheritsFrom
         ? {...this.originalInheritsFrom}
         : undefined;
-      this._inheritFromFilter = this.originalInheritsFrom?.name;
+      this.inheritFromFilter = this.originalInheritsFrom?.name;
     }
-    if (!this._local) {
+    if (!this.local) {
       return;
     }
-    for (const key of Object.keys(this._local)) {
-      if (this._local[key].added) {
-        delete this._local[key];
+    for (const key of Object.keys(this.local)) {
+      if (this.local[key].added) {
+        delete this.local[key];
       }
     }
   }
 
-  _updateRemoveObj(addRemoveObj: {remove: PropertyTreeNode}, path: string[]) {
+  private updateRemoveObj(
+    addRemoveObj: {remove: PropertyTreeNode},
+    path: string[]
+  ) {
     let curPos: PropertyTreeNode = addRemoveObj.remove;
     for (const item of path) {
       if (!curPos[item]) {
@@ -327,7 +488,7 @@
     return addRemoveObj;
   }
 
-  _updateAddObj(
+  private updateAddObj(
     addRemoveObj: {add: PropertyTreeNode},
     path: string[],
     value: PropertyTreeNode | PrimitiveValue
@@ -351,8 +512,10 @@
 
   /**
    * Used to recursively remove any objects with a 'deleted' bit.
+   *
+   * private but used in test
    */
-  _recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
+  recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
     if (!obj) return;
     for (const k of Object.keys(obj)) {
       const node = obj[k];
@@ -361,12 +524,13 @@
           delete obj[k];
           return;
         }
-        this._recursivelyRemoveDeleted(node);
+        this.recursivelyRemoveDeleted(node);
       }
     }
   }
 
-  _recursivelyUpdateAddRemoveObj(
+  // private but used in test
+  recursivelyUpdateAddRemoveObj(
     obj: PropertyTreeNode | undefined,
     addRemoveObj: {
       add: PropertyTreeNode;
@@ -381,36 +545,36 @@
         const updatedId = node.updatedId;
         const ref = updatedId ? updatedId : k;
         if (node.deleted) {
-          this._updateRemoveObj(addRemoveObj, path.concat(k));
+          this.updateRemoveObj(addRemoveObj, path.concat(k));
           continue;
         } else if (node.modified) {
-          this._updateRemoveObj(addRemoveObj, path.concat(k));
-          this._updateAddObj(addRemoveObj, path.concat(ref), node);
+          this.updateRemoveObj(addRemoveObj, path.concat(k));
+          this.updateAddObj(addRemoveObj, path.concat(ref), node);
           /* Special case for ref changes because they need to be added and
-           removed in a different way. The new ref needs to include all
-           changes but also the initial state. To do this, instead of
-           continuing with the same recursion, just remove anything that is
-           deleted in the current state. */
+          removed in a different way. The new ref needs to include all
+          changes but also the initial state. To do this, instead of
+          continuing with the same recursion, just remove anything that is
+          deleted in the current state. */
           if (updatedId && updatedId !== k) {
-            this._recursivelyRemoveDeleted(
+            this.recursivelyRemoveDeleted(
               addRemoveObj.add[updatedId] as PropertyTreeNode
             );
           }
           continue;
         } else if (node.added) {
-          this._updateAddObj(addRemoveObj, path.concat(ref), node);
+          this.updateAddObj(addRemoveObj, path.concat(ref), node);
           /**
            * As add / delete both can happen in the new section,
            * so here to make sure it will remove the deleted ones.
            *
            * @see Issue 11339
            */
-          this._recursivelyRemoveDeleted(
+          this.recursivelyRemoveDeleted(
             addRemoveObj.add[k] as PropertyTreeNode
           );
           continue;
         }
-        this._recursivelyUpdateAddRemoveObj(node, addRemoveObj, path.concat(k));
+        this.recursivelyUpdateAddRemoveObj(node, addRemoveObj, path.concat(k));
       }
     }
   }
@@ -418,8 +582,10 @@
   /**
    * Returns an object formatted for saving or submitting access changes for
    * review
+   *
+   * private but used in test
    */
-  _computeAddAndRemove() {
+  computeAddAndRemove() {
     const addRemoveObj: {
       add: PropertyTreeNode;
       remove: PropertyTreeNode;
@@ -432,8 +598,8 @@
     const originalInheritsFromId = this.originalInheritsFrom
       ? singleDecodeURL(this.originalInheritsFrom.id)
       : undefined;
-    const inheritsFromId = this._inheritsFrom
-      ? singleDecodeURL(this._inheritsFrom.id)
+    const inheritsFromId = this.inheritsFrom
+      ? singleDecodeURL(this.inheritsFrom.id)
       : undefined;
 
     const inheritFromChanged =
@@ -442,12 +608,12 @@
       // Inherit from added (did not have one initially);
       (!originalInheritsFromId && inheritsFromId);
 
-    if (!this._local) {
+    if (!this.local) {
       return addRemoveObj;
     }
 
-    this._recursivelyUpdateAddRemoveObj(
-      this._local as unknown as PropertyTreeNode,
+    this.recursivelyUpdateAddRemoveObj(
+      this.local as unknown as PropertyTreeNode,
       addRemoveObj
     );
 
@@ -457,30 +623,25 @@
     return addRemoveObj;
   }
 
-  _handleCreateSection() {
-    if (!this._local) {
-      return;
-    }
+  private handleCreateSection() {
+    if (!this.local) return;
     let newRef = 'refs/for/*';
     // Avoid using an already used key for the placeholder, since it
     // immediately gets added to an object.
-    while (this._local[newRef]) {
+    while (this.local[newRef]) {
       newRef = `${newRef}*`;
     }
     const section = {permissions: {}, added: true};
-    this.push('_sections', {id: newRef, value: section});
-    this.set(['_local', newRef], section);
-    flush();
+    this.sections!.push({id: newRef as GitRef, value: section});
+    this.local[newRef] = section;
+    this.requestUpdate();
+    assertIsDefined(this.accessSection, 'accessSection');
     // Template already instantiated at this point
-    (
-      this.root!.querySelector(
-        'gr-access-section:last-of-type'
-      ) as GrAccessSection
-    ).editReference();
+    this.accessSection.editReference();
   }
 
-  _getObjforSave(): ProjectAccessInput | undefined {
-    const addRemoveObj = this._computeAddAndRemove();
+  private getObjforSave(): ProjectAccessInput | undefined {
+    const addRemoveObj = this.computeAddAndRemove();
     // If there are no changes, don't actually save.
     if (
       !Object.keys(addRemoveObj.add).length &&
@@ -500,8 +661,9 @@
     return obj;
   }
 
-  _handleSave(e: Event) {
-    const obj = this._getObjforSave();
+  // private but used in test
+  handleSave(e: Event) {
+    const obj = this.getObjforSave();
     if (!obj) {
       return;
     }
@@ -516,18 +678,19 @@
     return this.restApiService
       .setRepoAccessRights(repo, obj)
       .then(() => {
-        this._reload(repo);
+        this.reload(repo);
       })
       .finally(() => {
-        this._modified = false;
+        this.modified = false;
         if (button) {
           button.loading = false;
         }
       });
   }
 
-  _handleSaveForReview(e: Event) {
-    const obj = this._getObjforSave();
+  // private but used in test
+  handleSaveForReview(e: Event) {
+    const obj = this.getObjforSave();
     if (!obj) {
       return;
     }
@@ -544,43 +707,42 @@
         GerritNav.navigateToChange(change);
       })
       .finally(() => {
-        this._modified = false;
+        this.modified = false;
         if (button) {
           button.loading = false;
         }
       });
   }
 
-  _computeSaveReviewBtnClass(canUpload?: boolean) {
-    return !canUpload ? 'invisible' : '';
-  }
-
-  _computeSaveBtnClass(ownerOf?: GitRef[]) {
-    return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
-  }
-
-  _computeMainClass(
-    ownerOf: GitRef[] | undefined,
-    canUpload: boolean,
-    editing: boolean
-  ) {
+  // private but used in test
+  computeMainClass() {
     const classList = [];
-    if ((ownerOf && ownerOf.length > 0) || canUpload) {
+    if ((this.ownerOf && this.ownerOf.length > 0) || this.canUpload) {
       classList.push('admin');
     }
-    if (editing) {
+    if (this.editing) {
       classList.push('editing');
     }
     return classList.join(' ');
   }
 
-  _computeParentHref(repoName: RepoName) {
-    return getBaseUrl() + `/admin/repos/${encodeURL(repoName, true)},access`;
+  computeParentHref() {
+    if (!this.inheritsFrom?.name) return '';
+    return `${getBaseUrl()}/admin/repos/${encodeURL(
+      this.inheritsFrom.name,
+      true
+    )},access`;
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo-access': GrRepoAccess;
+  private handleEditInheritFromTextChanged(e: ValueChangedEvent) {
+    this.inheritFromFilter = e.detail.value as RepoName;
+  }
+
+  private handleAccessSectionChanged(
+    e: ValueChangedEvent<PermissionAccessSection>,
+    index: number
+  ) {
+    this.sections![index] = e.detail.value;
+    this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
deleted file mode 100644
index 8f88619..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    gr-button,
-    #inheritsFrom,
-    #editInheritFromInput,
-    .editing #inheritFromName,
-    .weblinks,
-    .editing .invisible {
-      display: none;
-    }
-    #inheritsFrom.show {
-      display: flex;
-      min-height: 2em;
-      align-items: center;
-    }
-    .weblink {
-      margin-right: var(--spacing-xs);
-    }
-    gr-access-section {
-      margin-top: var(--spacing-l);
-    }
-    .weblinks.show,
-    .referenceContainer {
-      display: block;
-    }
-    .rightsText {
-      margin-right: var(--spacing-s);
-    }
-
-    .editing gr-button,
-    .admin #editBtn {
-      display: inline-block;
-      margin: var(--spacing-l) 0;
-    }
-    .editing #editInheritFromInput {
-      display: inline-block;
-    }
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class$="main [[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h3
-        id="inheritsFrom"
-        class$="heading-3 [[_computeShowInherit(_inheritsFrom)]]"
-      >
-        <span class="rightsText">Rights Inherit From</span>
-        <a
-          href$="[[_computeParentHref(_inheritsFrom.name)]]"
-          rel="noopener"
-          id="inheritFromName"
-        >
-          [[_inheritsFrom.name]]</a
-        >
-        <gr-autocomplete
-          id="editInheritFromInput"
-          text="{{_inheritFromFilter}}"
-          query="[[_query]]"
-          on-commit="_handleUpdateInheritFrom"
-          on-bind-value-changed="_handleUpdateInheritFrom"
-        ></gr-autocomplete>
-      </h3>
-      <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
-        History:
-        <template is="dom-repeat" items="[[_weblinks]]" as="link">
-          <a
-            href="[[link.url]]"
-            class="weblink"
-            rel="noopener"
-            target="[[link.target]]"
-          >
-            [[link.name]]
-          </a>
-        </template>
-      </div>
-      <template
-        is="dom-repeat"
-        items="{{_sections}}"
-        initial-count="5"
-        target-framerate="60"
-        as="section"
-      >
-        <gr-access-section
-          capabilities="[[_capabilities]]"
-          section="{{section}}"
-          labels="[[_labels]]"
-          can-upload="[[_canUpload]]"
-          editing="[[_editing]]"
-          owner-of="[[_ownerOf]]"
-          groups="[[_groups]]"
-          repo="[[repo]]"
-          on-added-section-removed="_handleAddedSectionRemoved"
-        ></gr-access-section>
-      </template>
-      <div class="referenceContainer">
-        <gr-button id="addReferenceBtn" on-click="_handleCreateSection"
-          >Add Reference</gr-button
-        >
-      </div>
-      <div>
-        <gr-button id="editBtn" on-click="_handleEdit"
-          >[[_editOrCancel(_editing)]]</gr-button
-        >
-        <gr-button
-          id="saveBtn"
-          primary=""
-          class$="[[_computeSaveBtnClass(_ownerOf)]]"
-          on-click="_handleSave"
-          disabled="[[!_modified]]"
-          >Save</gr-button
-        >
-        <gr-button
-          id="saveReviewBtn"
-          primary=""
-          class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
-          on-click="_handleSaveForReview"
-          disabled="[[!_modified]]"
-          >Save for review</gr-button
-        >
-      </div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index a5159ae..caa2d13 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -40,12 +40,10 @@
   AutocompleteCommitEvent,
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {GrPermission} from '../gr-permission/gr-permission';
 import {createChange} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-repo-access');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-repo-access tests', () => {
   let element: GrRepoAccess;
@@ -108,8 +106,8 @@
       'Code-Review': {
         values: {
           '0': 'No score',
-          '-1': 'I would prefer this is not merged as is',
-          '-2': 'This shall not be merged',
+          '-1': 'I would prefer this is not submitted as is',
+          '-2': 'This shall not be submitted',
           '+1': 'Looks good to me, but someone else must approve',
           '+2': 'Looks good to me, approved',
         },
@@ -128,19 +126,21 @@
     },
   };
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrRepoAccess>(html`
+      <gr-repo-access></gr-repo-access>
+    `);
     stubRestApi('getAccount').returns(Promise.resolve(undefined));
     repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
-    element._loading = false;
-    element._ownerOf = [];
-    element._canUpload = false;
-    await flush();
+    element.loading = false;
+    element.ownerOf = [];
+    element.canUpload = false;
+    await element.updateComplete;
   });
 
   test('_repoChanged called when repo name changes', async () => {
     const repoChangedStub = sinon.stub(element, '_repoChanged');
     element.repo = 'New Repo' as RepoName;
-    await flush();
+    await element.updateComplete;
     assert.isTrue(repoChangedStub.called);
   });
 
@@ -160,13 +160,13 @@
     assert.isTrue(accessStub.called);
     assert.isTrue(capabilitiesStub.called);
     assert.isTrue(repoStub.called);
-    assert.isNotOk(element._inheritsFrom);
-    assert.deepEqual(element._local, accessRes.local);
+    assert.isNotOk(element.inheritsFrom);
+    assert.deepEqual(element.local, accessRes.local);
     assert.deepEqual(
-      element._sections,
+      element.sections,
       toSortedPermissionsArray(accessRes.local)
     );
-    assert.deepEqual(element._labels, repoRes.labels);
+    assert.deepEqual(element.labels, repoRes.labels);
     assert.equal(
       getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
         .display,
@@ -175,7 +175,7 @@
 
     await element._repoChanged('Another New Repo' as RepoName);
     assert.deepEqual(
-      element._sections,
+      element.sections,
       toSortedPermissionsArray(accessRes2.local)
     );
     assert.equal(
@@ -205,56 +205,66 @@
     assert.isFalse(repoStub.called);
   });
 
-  test('_computeParentHref', () => {
-    assert.equal(
-      element._computeParentHref('test-repo' as RepoName),
-      '/admin/repos/test-repo,access'
-    );
+  test('computeParentHref', () => {
+    element.inheritsFrom!.name = 'test-repo' as RepoName;
+    assert.equal(element.computeParentHref(), '/admin/repos/test-repo,access');
   });
 
-  test('_computeMainClass', () => {
-    let ownerOf = ['refs/*'] as GitRef[];
-    const editing = true;
-    const canUpload = false;
-    assert.equal(element._computeMainClass(ownerOf, canUpload, false), 'admin');
-    assert.equal(
-      element._computeMainClass(ownerOf, canUpload, editing),
-      'admin editing'
-    );
-    ownerOf = [];
-    assert.equal(element._computeMainClass(ownerOf, canUpload, false), '');
-    assert.equal(
-      element._computeMainClass(ownerOf, canUpload, editing),
-      'editing'
-    );
+  test('computeMainClass', () => {
+    element.ownerOf = ['refs/*'] as GitRef[];
+    element.editing = false;
+    element.canUpload = false;
+    assert.equal(element.computeMainClass(), 'admin');
+    element.editing = true;
+    assert.equal(element.computeMainClass(), 'admin editing');
+    element.ownerOf = [];
+    element.editing = false;
+    assert.equal(element.computeMainClass(), '');
+    element.editing = true;
+    assert.equal(element.computeMainClass(), 'editing');
   });
 
   test('inherit section', async () => {
-    element._local = {};
-    element._ownerOf = [];
-    const computeParentHrefStub = sinon.stub(element, '_computeParentHref');
-    await flush();
+    element.local = {};
+    element.ownerOf = [];
+    const computeParentHrefStub = sinon.stub(element, 'computeParentHref');
+    await element.updateComplete;
 
     // Nothing should appear when no inherit from and not in edit mode.
-    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    // The autocomplete should be hidden, and the link should be  displayed.
-    assert.isFalse(computeParentHrefStub.called);
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
     // When in edit mode, the autocomplete should appear.
-    element._editing = true;
+    element.editing = true;
     // When editing, the autocomplete should still not be shown.
-    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
 
-    element._editing = false;
-    element._inheritsFrom = {
+    element.editing = false;
+    element.inheritsFrom = {
       id: '1234' as UrlEncodedRepoName,
       name: 'another-repo' as RepoName,
     };
-    await flush();
+    await element.updateComplete;
 
     // When there is a parent project, the link should be displayed.
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
     assert.notEqual(
-      getComputedStyle(element.$.inheritFromName).display,
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<HTMLAnchorElement>(element, '#inheritFromName')
+      ).display,
       'none'
     );
     assert.equal(
@@ -264,10 +274,21 @@
       'none'
     );
     assert.isTrue(computeParentHrefStub.called);
-    element._editing = true;
+    element.editing = true;
+    await element.updateComplete;
     // When editing, the autocomplete should be shown.
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLAnchorElement>(element, '#inheritFromName')
+      ).display,
+      'none'
+    );
     assert.notEqual(
       getComputedStyle(
         queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
@@ -276,20 +297,16 @@
     );
   });
 
-  test('_handleUpdateInheritFrom', async () => {
-    element._inheritFromFilter = 'foo bar baz' as RepoName;
-    element._handleUpdateInheritFrom({
+  test('handleUpdateInheritFrom', async () => {
+    element.inheritFromFilter = 'foo bar baz' as RepoName;
+    await element.updateComplete;
+    element.handleUpdateInheritFrom({
       detail: {value: 'abc+123'},
     } as CustomEvent);
-    await flush();
-    assert.isOk(element._inheritsFrom);
-    assert.equal(element._inheritsFrom!.id, 'abc+123');
-    assert.equal(element._inheritsFrom!.name, 'foo bar baz' as RepoName);
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
+    await element.updateComplete;
+    assert.isOk(element.inheritsFrom);
+    assert.equal(element.inheritsFrom!.id, 'abc+123');
+    assert.equal(element.inheritsFrom!.name, 'foo bar baz' as RepoName);
   });
 
   test('fires page-error', async () => {
@@ -341,10 +358,10 @@
         ).display,
         'none'
       );
-      element._inheritsFrom = {
+      element.inheritsFrom = {
         id: 'test-project' as UrlEncodedRepoName,
       };
-      await flush();
+      await element.updateComplete;
       assert.equal(
         getComputedStyle(
           queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
@@ -352,8 +369,8 @@
         'none'
       );
 
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#editBtn'));
-      await flush();
+      queryAndAssert<GrButton>(element, '#editBtn').click();
+      await element.updateComplete;
 
       // Edit button changes to Cancel button, and Save button is visible but
       // disabled.
@@ -406,19 +423,19 @@
     setup(async () => {
       // Create deep copies of these objects so the originals are not modified
       // by any tests.
-      element._local = JSON.parse(JSON.stringify(accessRes.local));
-      element._ownerOf = [];
-      element._sections = toSortedPermissionsArray(element._local);
-      element._groups = JSON.parse(JSON.stringify(accessRes.groups));
-      element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
-      element._labels = JSON.parse(JSON.stringify(repoRes.labels));
-      await flush();
+      element.local = JSON.parse(JSON.stringify(accessRes.local));
+      element.ownerOf = [];
+      element.sections = toSortedPermissionsArray(element.local);
+      element.groups = JSON.parse(JSON.stringify(accessRes.groups));
+      element.capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
+      element.labels = JSON.parse(JSON.stringify(repoRes.labels));
+      await element.updateComplete;
     });
 
     test('removing an added section', async () => {
-      element._editing = true;
-      await flush();
-      assert.equal(element._sections!.length, 1);
+      element.editing = true;
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 1);
       queryAndAssert<GrAccessSection>(
         element,
         'gr-access-section'
@@ -428,31 +445,38 @@
           bubbles: true,
         })
       );
-      await flush();
-      assert.equal(element._sections!.length, 0);
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 0);
     });
 
-    test('button visibility for non ref owner', () => {
-      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
+    test('button visibility for non ref owner', async () => {
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn')).display,
+        'none'
+      );
     });
 
     test('button visibility for non ref owner with upload privilege', async () => {
-      element._canUpload = true;
-      await flush();
+      element.canUpload = true;
+      await element.updateComplete;
       testEditSaveCancelBtns(false, true);
     });
 
     test('button visibility for ref owner', async () => {
-      element._ownerOf = ['refs/for/*'] as GitRef[];
-      await flush();
+      element.ownerOf = ['refs/for/*'] as GitRef[];
+      await element.updateComplete;
       testEditSaveCancelBtns(true, false);
     });
 
     test('button visibility for ref owner and upload', async () => {
-      element._ownerOf = ['refs/for/*'] as GitRef[];
-      element._canUpload = true;
-      await flush();
+      element.ownerOf = ['refs/for/*'] as GitRef[];
+      element.canUpload = true;
+      await element.updateComplete;
       testEditSaveCancelBtns(true, false);
     });
 
@@ -467,15 +491,15 @@
           bubbles: true,
         })
       );
-      await flush();
+      await element.updateComplete;
       assert.isTrue(handleAccessModifiedSpy.called);
     });
 
     test('_handleAccessModified called when parent changes', async () => {
-      element._inheritsFrom = {
+      element.inheritsFrom = {
         id: 'test-project' as UrlEncodedRepoName,
       };
-      await flush();
+      await element.updateComplete;
       queryAndAssert<GrAutocomplete>(
         element,
         '#editInheritFromInput'
@@ -497,22 +521,22 @@
           bubbles: true,
         })
       );
-      await flush();
+      await element.updateComplete;
       assert.isTrue(handleAccessModifiedSpy.called);
     });
 
-    test('_handleSaveForReview', async () => {
+    test('handleSaveForReview', async () => {
       const saveStub = stubRestApi('setRepoAccessRightsForReview');
-      sinon.stub(element, '_computeAddAndRemove').returns({
+      sinon.stub(element, 'computeAddAndRemove').returns({
         add: {},
         remove: {},
       });
-      element._handleSaveForReview(new Event('test'));
-      await flush();
+      element.handleSaveForReview(new Event('test'));
+      await element.updateComplete;
       assert.isFalse(saveStub.called);
     });
 
-    test('_recursivelyRemoveDeleted', () => {
+    test('recursivelyRemoveDeleted', () => {
       const obj = {
         'refs/*': {
           permissions: {
@@ -542,11 +566,11 @@
           },
         },
       };
-      element._recursivelyRemoveDeleted(obj);
+      element.recursivelyRemoveDeleted(obj);
       assert.deepEqual(obj, expectedResult);
     });
 
-    test('_recursivelyUpdateAddRemoveObj on new added section', () => {
+    test('recursivelyUpdateAddRemoveObj on new added section', () => {
       const obj = {
         'refs/for/*': {
           permissions: {
@@ -608,46 +632,46 @@
         remove: {},
       };
       const updateObj = {add: {}, remove: {}};
-      element._recursivelyUpdateAddRemoveObj(obj, updateObj);
+      element.recursivelyUpdateAddRemoveObj(obj, updateObj);
       assert.deepEqual(updateObj, expectedResult);
     });
 
-    test('_handleSaveForReview with no changes', () => {
-      assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+    test('handleSaveForReview with no changes', () => {
+      assert.deepEqual(element.computeAddAndRemove(), {add: {}, remove: {}});
     });
 
-    test('_handleSaveForReview parent change', async () => {
-      element._inheritsFrom = {
+    test('handleSaveForReview parent change', async () => {
+      element.inheritsFrom = {
         id: 'test-project' as UrlEncodedRepoName,
       };
       element.originalInheritsFrom = {
         id: 'test-project-original' as UrlEncodedRepoName,
       };
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), {
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), {
         parent: 'test-project',
         add: {},
         remove: {},
       });
     });
 
-    test('_handleSaveForReview new parent with spaces', async () => {
-      element._inheritsFrom = {
+    test('handleSaveForReview new parent with spaces', async () => {
+      element.inheritsFrom = {
         id: 'spaces+in+project+name' as UrlEncodedRepoName,
       };
       element.originalInheritsFrom = {id: 'old-project' as UrlEncodedRepoName};
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), {
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), {
         parent: 'spaces in project name',
         add: {},
         remove: {},
       });
     });
 
-    test('_handleSaveForReview rules', async () => {
+    test('handleSaveForReview rules', async () => {
       // Delete a rule.
-      element._local!['refs/*'].permissions.owner.rules[123].deleted = true;
-      await flush();
+      element.local!['refs/*'].permissions.owner.rules[123].deleted = true;
+      await element.updateComplete;
       let expectedInput = {
         add: {},
         remove: {
@@ -662,14 +686,14 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Undo deleting a rule.
-      delete element._local!['refs/*'].permissions.owner.rules[123].deleted;
+      delete element.local!['refs/*'].permissions.owner.rules[123].deleted;
 
       // Modify a rule.
-      element._local!['refs/*'].permissions.owner.rules[123].modified = true;
-      await flush();
+      element.local!['refs/*'].permissions.owner.rules[123].modified = true;
+      await element.updateComplete;
       expectedInput = {
         add: {
           'refs/*': {
@@ -694,10 +718,10 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove permissions', async () => {
+    test('computeAddAndRemove permissions', async () => {
       // Add a new rule to a permission.
       let expectedInput = {};
 
@@ -722,22 +746,20 @@
         element,
         'gr-access-section'
       );
-      queryAndAssert<GrPermission>(
+      await queryAndAssert<GrPermission>(
         grAccessSection,
         'gr-permission'
-      )._handleAddRuleItem({
+      ).handleAddRuleItem({
         detail: {value: 'Maintainers'},
       } as AutocompleteCommitEvent);
-
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Remove the added rule.
-      delete element._local!['refs/*'].permissions.owner.rules.Maintainers;
+      delete element.local!['refs/*'].permissions.owner.rules.Maintainers;
 
       // Delete a permission.
-      element._local!['refs/*'].permissions.owner.deleted = true;
-      await flush();
+      element.local!['refs/*'].permissions.owner.deleted = true;
+      await element.updateComplete;
 
       expectedInput = {
         add: {},
@@ -749,14 +771,14 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Undo delete permission.
-      delete element._local!['refs/*'].permissions.owner.deleted;
+      delete element.local!['refs/*'].permissions.owner.deleted;
 
       // Modify a permission.
-      element._local!['refs/*'].permissions.owner.modified = true;
-      await flush();
+      element.local!['refs/*'].permissions.owner.modified = true;
+      await element.updateComplete;
       expectedInput = {
         add: {
           'refs/*': {
@@ -779,10 +801,10 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove sections', async () => {
+    test('computeAddAndRemove sections', async () => {
       // Add a new permission to a section
       let expectedInput = {};
 
@@ -803,9 +825,9 @@
       queryAndAssert<GrAccessSection>(
         element,
         'gr-access-section'
-      )._handleAddPermission();
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      ).handleAddPermission();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Add a new rule to the new permission.
       expectedInput = {
@@ -833,20 +855,19 @@
         element,
         'gr-access-section'
       );
-      const newPermission = queryAll<GrPermission>(
+      await queryAll<GrPermission>(
         grAccessSection,
         'gr-permission'
-      )[2];
-      newPermission._handleAddRuleItem({
+      )[2].handleAddRuleItem({
         detail: {value: 'Maintainers'},
       } as AutocompleteCommitEvent);
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Modify a section reference.
-      element._local!['refs/*'].updatedId = 'refs/for/bar';
-      element._local!['refs/*'].modified = true;
-      await flush();
+      element.local!['refs/*'].updatedId = 'refs/for/bar';
+      element.local!['refs/*'].modified = true;
+      await element.updateComplete;
+
       expectedInput = {
         add: {
           'refs/for/bar': {
@@ -885,12 +906,11 @@
           },
         },
       };
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Delete a section.
-      element._local!['refs/*'].deleted = true;
-      await flush();
+      element.local!['refs/*'].deleted = true;
+      await element.updateComplete;
       expectedInput = {
         add: {},
         remove: {
@@ -899,10 +919,10 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove new section', async () => {
+    test('computeAddAndRemove new section', async () => {
       // Add a new permission to a section
       let expectedInput = {};
 
@@ -915,9 +935,9 @@
         },
         remove: {},
       };
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       expectedInput = {
         add: {
@@ -938,9 +958,9 @@
         element,
         'gr-access-section'
       )[1];
-      newSection._handleAddPermission();
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Add rule to the new permission.
       expectedInput = {
@@ -966,18 +986,17 @@
         remove: {},
       };
 
-      queryAndAssert<GrPermission>(
+      await queryAndAssert<GrPermission>(
         newSection,
         'gr-permission'
-      )._handleAddRuleItem({
+      ).handleAddRuleItem({
         detail: {value: 'Maintainers'},
       } as AutocompleteCommitEvent);
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Modify a the reference from the default value.
-      element._local!['refs/for/*'].updatedId = 'refs/for/new';
-      await flush();
+      element.local!['refs/for/*'].updatedId = 'refs/for/new';
+      await element.updateComplete;
       expectedInput = {
         add: {
           'refs/for/new': {
@@ -1001,14 +1020,14 @@
         },
         remove: {},
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove combinations', async () => {
+    test('computeAddAndRemove combinations', async () => {
       // Modify rule and delete permission that it is inside of.
-      element._local!['refs/*'].permissions.owner.rules[123].modified = true;
-      element._local!['refs/*'].permissions.owner.deleted = true;
-      await flush();
+      element.local!['refs/*'].permissions.owner.rules[123].modified = true;
+      element.local!['refs/*'].permissions.owner.deleted = true;
+      await element.updateComplete;
       let expectedInput = {};
 
       expectedInput = {
@@ -1021,16 +1040,16 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
       // Delete rule and delete permission that it is inside of.
-      element._local!['refs/*'].permissions.owner.rules[123].modified = false;
-      element._local!['refs/*'].permissions.owner.rules[123].deleted = true;
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      element.local!['refs/*'].permissions.owner.rules[123].modified = false;
+      element.local!['refs/*'].permissions.owner.rules[123].deleted = true;
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Also modify a different rule inside of another permission.
-      element._local!['refs/*'].permissions.read.modified = true;
-      await flush();
+      element.local!['refs/*'].permissions.read.modified = true;
+      await element.updateComplete;
       expectedInput = {
         add: {
           'refs/*': {
@@ -1053,14 +1072,14 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
       // Modify both permissions with an exclusive bit. Owner is still
       // deleted.
-      element._local!['refs/*'].permissions.owner.exclusive = true;
-      element._local!['refs/*'].permissions.owner.modified = true;
-      element._local!['refs/*'].permissions.read.exclusive = true;
-      element._local!['refs/*'].permissions.read.modified = true;
-      await flush();
+      element.local!['refs/*'].permissions.owner.exclusive = true;
+      element.local!['refs/*'].permissions.owner.modified = true;
+      element.local!['refs/*'].permissions.read.exclusive = true;
+      element.local!['refs/*'].permissions.read.modified = true;
+      await element.updateComplete;
       expectedInput = {
         add: {
           'refs/*': {
@@ -1084,21 +1103,19 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Add a rule to the existing permission;
       const grAccessSection = queryAndAssert<GrAccessSection>(
         element,
         'gr-access-section'
       );
-      const readPermission = queryAll<GrPermission>(
+      await queryAll<GrPermission>(
         grAccessSection,
         'gr-permission'
-      )[1];
-      readPermission._handleAddRuleItem({
+      )[1].handleAddRuleItem({
         detail: {value: 'Maintainers'},
       } as AutocompleteCommitEvent);
-      await flush();
 
       expectedInput = {
         add: {
@@ -1124,12 +1141,12 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Change one of the refs
-      element._local!['refs/*'].updatedId = 'refs/for/bar';
-      element._local!['refs/*'].modified = true;
-      await flush();
+      element.local!['refs/*'].updatedId = 'refs/for/bar';
+      element.local!['refs/*'].modified = true;
+      await element.updateComplete;
 
       expectedInput = {
         add: {
@@ -1154,7 +1171,7 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       expectedInput = {
         add: {},
@@ -1164,27 +1181,28 @@
           },
         },
       };
-      element._local!['refs/*'].deleted = true;
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      element.local!['refs/*'].deleted = true;
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Add a new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
       let newSection = queryAll<GrAccessSection>(
         element,
         'gr-access-section'
       )[1];
-      newSection._handleAddPermission();
-      await flush();
-      queryAndAssert<GrPermission>(
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      await queryAndAssert<GrPermission>(
         newSection,
         'gr-permission'
-      )._handleAddRuleItem({
+      ).handleAddRuleItem({
         detail: {value: 'Maintainers'},
       } as AutocompleteCommitEvent);
       // Modify a the reference from the default value.
-      element._local!['refs/for/*'].updatedId = 'refs/for/new';
-      await flush();
+      element.local!['refs/for/*'].updatedId = 'refs/for/new';
+      await element.updateComplete;
 
       expectedInput = {
         add: {
@@ -1213,13 +1231,13 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Modify newly added rule inside new ref.
-      element._local!['refs/for/*'].permissions['label-Code-Review'].rules[
+      element.local!['refs/for/*'].permissions['label-Code-Review'].rules[
         'Maintainers'
       ].modified = true;
-      await flush();
+      await element.updateComplete;
       expectedInput = {
         add: {
           'refs/for/new': {
@@ -1248,23 +1266,23 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
 
       // Add a second new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
       newSection = queryAll<GrAccessSection>(element, 'gr-access-section')[2];
-      newSection._handleAddPermission();
-      await flush();
-      queryAndAssert<GrPermission>(
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      await queryAndAssert<GrPermission>(
         newSection,
         'gr-permission'
-      )._handleAddRuleItem({
+      ).handleAddRuleItem({
         detail: {value: 'Maintainers'},
       } as AutocompleteCommitEvent);
       // Modify a the reference from the default value.
-      element._local!['refs/for/**'].updatedId = 'refs/for/new2';
-      await flush();
+      element.local!['refs/for/**'].updatedId = 'refs/for/new2';
+      await element.updateComplete;
       expectedInput = {
         add: {
           'refs/for/new': {
@@ -1311,26 +1329,26 @@
           },
         },
       };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
     });
 
     test('Unsaved added refs are discarded when edit cancelled', async () => {
       // Unsaved changes are discarded when editing is cancelled.
-      MockInteractions.tap(element.$.editBtn);
-      await flush();
-      assert.equal(element._sections!.length, 1);
-      assert.equal(Object.keys(element._local!).length, 1);
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
-      assert.equal(element._sections!.length, 2);
-      assert.equal(Object.keys(element._local!).length, 2);
-      MockInteractions.tap(element.$.editBtn);
-      await flush();
-      assert.equal(element._sections!.length, 1);
-      assert.equal(Object.keys(element._local!).length, 1);
+      queryAndAssert<GrButton>(element, '#editBtn').click();
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 1);
+      assert.equal(Object.keys(element.local!).length, 1);
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 2);
+      assert.equal(Object.keys(element.local!).length, 2);
+      queryAndAssert<GrButton>(element, '#editBtn').click();
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 1);
+      assert.equal(Object.keys(element.local!).length, 1);
     });
 
-    test('_handleSave', async () => {
+    test('handleSave', async () => {
       const repoAccessInput = {
         add: {
           'refs/*': {
@@ -1365,19 +1383,22 @@
       );
 
       element.repo = 'test-repo' as RepoName;
-      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
 
-      element._modified = true;
-      MockInteractions.tap(element.$.saveBtn);
-      await flush();
-      assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
+      element.modified = true;
+      queryAndAssert<GrButton>(element, '#saveBtn').click();
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('loading'),
+        true
+      );
       resolver!({status: 200} as Response);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(saveStub.called);
       assert.isTrue(navigateToChangeStub.notCalled);
     });
 
-    test('_handleSaveForReview', async () => {
+    test('handleSaveForReview', async () => {
       const repoAccessInput = {
         add: {
           'refs/*': {
@@ -1412,14 +1433,19 @@
       ).returns(new Promise(r => (resolver = r)));
 
       element.repo = 'test-repo' as RepoName;
-      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
 
-      element._modified = true;
-      MockInteractions.tap(element.$.saveReviewBtn);
-      await flush();
-      assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
+      element.modified = true;
+      queryAndAssert<GrButton>(element, '#saveReviewBtn').click();
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveReviewBtn').hasAttribute(
+          'loading'
+        ),
+        true
+      );
       resolver!(createChange());
-      await flush();
+      await element.updateComplete;
       assert.isTrue(saveForReviewStub.called);
       assert.isTrue(
         navigateToChangeStub.lastCall.calledWithExactly(createChange())
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 990b34d..f7ad3b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -108,10 +108,10 @@
     return html`
       <div class="main gr-form-styles read-only">
         <h1 id="Title" class="heading-1">Repository Commands</h1>
-        <div id="loading" class="${this.loading ? 'loading' : ''}">
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
           Loading...
         </div>
-        <div id="loadedContent" class="${this.loading ? 'loading' : ''}">
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
           <h2 id="options" class="heading-2">Command</h2>
           <div id="form">
             <h3 class="heading-3">Create change</h3>
@@ -137,7 +137,7 @@
             <gr-endpoint-decorator name="repo-command">
               <gr-endpoint-param name="config" .value=${this.repoConfig}>
               </gr-endpoint-param>
-              <gr-endpoint-param name="repoName" .value="${this.repo}">
+              <gr-endpoint-param name="repoName" .value=${this.repo}>
               </gr-endpoint-param>
             </gr-endpoint-decorator>
           </div>
@@ -159,8 +159,8 @@
           <div class="main" slot="main">
             <gr-create-change-dialog
               id="createNewChangeModal"
-              .repoName="${this.repo}"
-              .privateByDefault="${this.repoConfig?.private_by_default}"
+              .repoName=${this.repo}
+              .privateByDefault=${this.repoConfig?.private_by_default}
               @can-create-change=${() => {
                 this.handleCanCreateChange();
               }}
@@ -178,7 +178,7 @@
     return html`
       <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
       <gr-button
-        title="${this.repoConfig?.actions['gc']?.title || ''}"
+        title=${this.repoConfig?.actions['gc']?.title || ''}
         ?loading=${this.runningGC}
         @click=${() => this.handleRunningGC()}
       >
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index 3a37971..c2d7615 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -90,7 +90,7 @@
               info => html`
                 <tr class="table">
                   <td class="name">
-                    <a href="${this._getUrl(info.project, info.id)}"
+                    <a href=${this._getUrl(info.project, info.id)}
                       >${info.path}</a
                     >
                   </td>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
index a54eafb..7c26909 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
@@ -115,9 +115,9 @@
       );
 
       const dashboard = element._dashboards!;
-      assert.equal(dashboard.length!, 2);
-      assert.equal(dashboard[0].section!, 'custom');
-      assert.equal(dashboard[1].section!, 'default');
+      assert.equal(dashboard.length, 2);
+      assert.equal(dashboard[0].section, 'custom');
+      assert.equal(dashboard[1].section, 'default');
 
       const dashboards = dashboard[0].dashboards;
       assert.equal(dashboards.length, 2);
@@ -148,6 +148,7 @@
     test('fires page-error', async () => {
       const response = {status: 404} as Response;
       stubRestApi('getRepoDashboards').callsFake((_repo, errFn) => {
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         errFn!(response);
         return Promise.resolve([]);
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 504ae67..d72f916 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -16,7 +16,7 @@
  */
 
 import '@polymer/iron-input/iron-input';
-import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-dialog/gr-dialog';
@@ -342,7 +342,7 @@
 
     return html`
       <div class="tagger">
-        <gr-account-link .account=${tagger}> </gr-account-link>
+        <gr-account-label .account=${tagger} clickable> </gr-account-label>
         (<gr-date-formatter withTooltip .dateStr=${tagger.date}>
         </gr-date-formatter
         >)
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 48983d7..07e89a4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -133,7 +133,7 @@
               <td>Loading...</td>
             </tr>
           </tbody>
-          <tbody class="${this.computeLoadingClass(this.loading)}">
+          <tbody class=${this.computeLoadingClass(this.loading)}>
             ${this.renderRepoList()}
           </tbody>
         </table>
@@ -168,11 +168,11 @@
     return html`
       <tr class="table">
         <td class="name">
-          <a href="${this.computeRepoUrl(item.name)}">${item.name}</a>
+          <a href=${this.computeRepoUrl(item.name)}>${item.name}</a>
         </td>
         <td class="repositoryBrowser">${this.renderWebLinks(item)}</td>
         <td class="changesLink">
-          <a href="${this.computeChangesLink(item.name)}">view all</a>
+          <a href=${this.computeChangesLink(item.name)}>view all</a>
         </td>
         <td class="readOnly">
           ${item.state === ProjectState.READ_ONLY ? 'Y' : ''}
@@ -189,7 +189,7 @@
 
   private renderWebLink(link: WebLinkInfo) {
     return html`
-      <a href="${link.url}" class="webLink" rel="noopener" target="_blank">
+      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
         ${link.name}
       </a>
     `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index f9f760c..d5e5027 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -136,7 +136,7 @@
     return html` <gr-tooltip-content
       has-tooltip
       show-icon
-      title="${option.info.description}"
+      title=${option.info.description}
     >
       ${titleName}
     </gr-tooltip-content>`;
@@ -147,7 +147,7 @@
       return html`
         <gr-plugin-config-array-editor
           @plugin-config-option-changed=${this._handleArrayChange}
-          .pluginOption="${option}"
+          .pluginOption=${option}
           ?disabled=${this.disabled || !option.info.editable}
         ></gr-plugin-config-array-editor>
       `;
@@ -172,7 +172,7 @@
             ?disabled=${this.disabled || !option.info.editable}
           >
             ${(option.info.permitted_values || []).map(
-              value => html`<option value="${value}">${value}</option>`
+              value => html`<option value=${value}>${value}</option>`
             )}
           </select>
         </gr-select>
@@ -185,13 +185,13 @@
       return html`
         <iron-input
           @input=${this._handleStringChange}
-          data-option-key="${option._key}"
+          data-option-key=${option._key}
         >
           <input
             is="iron-input"
-            .value="${option.info.value ?? ''}"
+            .value=${option.info.value ?? ''}
             @input=${this._handleStringChange}
-            data-option-key="${option._key}"
+            data-option-key=${option._key}
             ?disabled=${this.disabled || !option.info.editable}
           />
         </iron-input>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index a3f7374..fc2b789 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -127,12 +127,16 @@
 
   constructor() {
     super();
-    subscribe(this, this.userModel.preferences$, prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this.selectedScheme = prefs.download_scheme.toLowerCase();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        if (prefs?.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+        }
       }
-    });
+    );
   }
 
   override connectedCallback() {
@@ -811,7 +815,7 @@
         config.submit_type = config.default_submit_type.configured_value;
       }
       if (!config.state) {
-        config.state = STATES.active.value as ProjectState;
+        config.state = STATES.active.value;
       }
       // To properly check if the config has changed we need it to be a string
       // as it's converted to a string in the input.
@@ -1105,7 +1109,7 @@
 
   private computeChangesUrl(name?: RepoName) {
     if (!name) return '';
-    return GerritNav.getUrlForProjectChanges(name as RepoName);
+    return GerritNav.getUrlForProjectChanges(name);
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 82338d3..65eee70 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -214,7 +214,7 @@
     });
     await element.updateComplete;
 
-    assert.deepEqual(element.repoConfig!.plugin_config!.test, {
+    assert.deepEqual(element.repoConfig.plugin_config!.test, {
       test: {display_name: 'test plugin', type: 'STRING'},
     } as PluginParameterToConfigParameterInfoMap);
     assert.isTrue(requestUpdateStub.called);
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index bb885dd..1685ca4 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -220,15 +220,13 @@
           ${this.renderMinAndMaxLabel()} ${this.renderMinAndMaxInput()}
           <a
             class="groupPath"
-            href="${ifDefined(this.computeGroupPath(this.groupId))}"
+            href=${ifDefined(this.computeGroupPath(this.groupId))}
           >
             ${this.groupName}
           </a>
           <gr-select
             id="force"
-            class="${this.computeForce(this.rule?.value?.action)
-              ? 'force'
-              : ''}"
+            class=${this.computeForce(this.rule?.value?.action) ? 'force' : ''}
             .bindValue=${this.rule?.value?.force}
             @bind-value-changed=${(e: BindValueChangeEvent) => {
               this.handleForceBindValueChanged(e);
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
index b4b905b..047bbb6 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -188,8 +188,8 @@
       element.permission = 'label-Code-Review' as AccessPermissionId;
       element.label = {
         values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -2, text: 'This shall not be submitted'},
+          {value: -1, text: 'I would prefer this is not submitted as is'},
           {value: -0, text: 'No score'},
           {value: 1, text: 'Looks good to me, but someone else must approve'},
           {value: 2, text: 'Looks good to me, approved'},
@@ -220,7 +220,7 @@
         .returns(defaultValue);
       element.setDefaultRuleValues();
       assert.isTrue(getDefaultRuleValuesStub.called);
-      assert.equal(element.rule!.value, defaultValue);
+      assert.equal(element.rule.value, defaultValue);
     });
 
     test('computeOptions', () => {
@@ -243,10 +243,10 @@
       };
       element.addEventListener('access-modified', modifiedHandler);
       element.handleValueChange();
-      assert.isNotOk(element.rule!.value!.modified);
+      assert.isNotOk(element.rule.value!.modified);
       element.originalRuleValues = {action: PermissionAction.ALLOW};
       element.handleValueChange();
-      assert.isTrue(element.rule!.value!.modified);
+      assert.isTrue(element.rule.value!.modified);
       assert.isTrue(modifiedHandler.called);
     });
 
@@ -313,11 +313,11 @@
           .display,
         'none'
       );
-      assert.isNotOk(element.rule!.value!.modified);
+      assert.isNotOk(element.rule.value!.modified);
       const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
       actionBindValue.bindValue = PermissionAction.DENY;
       await element.updateComplete;
-      assert.isTrue(element.rule!.value!.modified);
+      assert.isTrue(element.rule.value!.modified);
       element.editing = false;
       await element.updateComplete;
       assert.equal(
@@ -325,8 +325,8 @@
           .display,
         'none'
       );
-      assert.deepEqual(element.originalRuleValues, element.rule!.value);
-      assert.isNotOk(element.rule!.value!.modified);
+      assert.deepEqual(element.originalRuleValues, element.rule.value);
+      assert.isNotOk(element.rule.value!.modified);
       assert.equal(element.rule?.value?.action, PermissionAction.ALLOW);
       assert.equal(
         queryAndAssert<GrSelect>(element, '#action').bindValue,
@@ -376,12 +376,12 @@
         ).classList.contains('deleted')
       );
       assert.isTrue(element.deleted);
-      assert.isTrue(element.rule!.value!.deleted);
+      assert.isTrue(element.rule.value!.deleted);
 
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
       await element.updateComplete;
       assert.isFalse(element.deleted);
-      assert.isNotOk(element.rule!.value!.deleted);
+      assert.isNotOk(element.rule.value!.deleted);
     });
 
     test('remove rule and cancel', async () => {
@@ -415,15 +415,15 @@
         'none'
       );
       assert.isTrue(element.deleted);
-      assert.isTrue(element.rule!.value!.deleted);
+      assert.isTrue(element.rule.value!.deleted);
 
       element.editing = false;
       await element.updateComplete;
       assert.isFalse(element.deleted);
-      assert.isNotOk(element.rule!.value!.deleted);
-      assert.isNotOk(element.rule!.value!.modified);
+      assert.isNotOk(element.rule.value!.deleted);
+      assert.isNotOk(element.rule.value!.modified);
 
-      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+      assert.deepEqual(element.originalRuleValues, element.rule.value);
       assert.equal(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
           .display,
@@ -451,7 +451,7 @@
       element.section = 'refs/*';
       element.setupValues();
       await element.updateComplete;
-      element.rule!.value!.added = true;
+      element.rule.value!.added = true;
       await element.updateComplete;
       element.connectedCallback();
     });
@@ -503,8 +503,8 @@
     setup(async () => {
       element.label = {
         values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -2, text: 'This shall not be submitted'},
+          {value: -1, text: 'I would prefer this is not submitted as is'},
           {value: -0, text: 'No score'},
           {value: 1, text: 'Looks good to me, but someone else must approve'},
           {value: 2, text: 'Looks good to me, approved'},
@@ -570,8 +570,8 @@
       setDefaultRuleValuesSpy = sinon.spy(element, 'setDefaultRuleValues');
       element.label = {
         values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -2, text: 'This shall not be submitted'},
+          {value: -1, text: 'I would prefer this is not submitted as is'},
           {value: -0, text: 'No score'},
           {value: 1, text: 'Looks good to me, but someone else must approve'},
           {value: 2, text: 'Looks good to me, approved'},
@@ -583,7 +583,7 @@
       element.section = 'refs/*';
       element.setupValues();
       await element.updateComplete;
-      element.rule!.value!.added = true;
+      element.rule.value!.added = true;
       await element.updateComplete;
       element.connectedCallback();
     });
@@ -595,8 +595,8 @@
       assert.isTrue(setDefaultRuleValuesSpy.called);
 
       const expectedRuleValue = {
-        max: element.label!.values![element.label!.values.length - 1].value,
-        min: element.label!.values![0].value,
+        max: element.label!.values[element.label!.values.length - 1].value,
+        min: element.label!.values[0].value,
         action: PermissionAction.ALLOW,
         added: true,
       };
@@ -685,7 +685,7 @@
       element.section = 'refs/*';
       element.setupValues();
       await element.updateComplete;
-      element.rule!.value!.added = true;
+      element.rule.value!.added = true;
       await element.updateComplete;
       element.connectedCallback();
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
new file mode 100644
index 0000000..596e850
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -0,0 +1,128 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {pluralize} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
+import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
+import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
+import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
+
+/**
+ * An action bar for the top of a <gr-change-list-section> element. Assumes it
+ * will be used inside a <tr> element.
+ */
+@customElement('gr-change-list-action-bar')
+export class GrChangeListActionBar extends LitElement {
+  static override get styles() {
+    return css`
+      :host {
+        display: contents;
+      }
+      .container {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+      }
+      /*
+       * checkbox styles match checkboxes in <gr-change-list-item> rows to
+       * vertically align with them.
+       */
+      input {
+        background-color: var(--background-color-primary);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-sizing: border-box;
+        color: var(--primary-text-color);
+        margin: 0px;
+        padding: var(--spacing-s);
+        vertical-align: middle;
+      }
+    `;
+  }
+
+  @state()
+  private numSelected = 0;
+
+  @state()
+  private totalChangeCount = 0;
+
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => (this.numSelected = selectedChangeNums.length)
+    );
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().totalChangeCount$,
+      totalChangeCount => (this.totalChangeCount = totalChangeCount)
+    );
+  }
+
+  override render() {
+    const numSelectedLabel = `${pluralize(
+      this.numSelected,
+      'change'
+    )} selected`;
+    const checked =
+      this.numSelected > 0 && this.numSelected === this.totalChangeCount;
+    const indeterminate =
+      this.numSelected > 0 && this.numSelected !== this.totalChangeCount;
+    return html`
+      <!-- Empty cell added for spacing just like gr-change-list-item rows -->
+      <td></td>
+      <td>
+        <!--
+          The .checked property must be used rather than the attribute because
+          the attribute only controls the default checked state and does not
+          update the current checked state.
+          See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
+        -->
+        <input
+          type="checkbox"
+          .checked=${checked}
+          .indeterminate=${indeterminate}
+          @click=${() => this.getBulkActionsModel().clearSelectedChangeNums()}
+        />
+      </td>
+      <!--
+        500 chosen to be more than the actual number of columns but less than
+        1000 where the browser apparently decides it is an error and reverts
+        back to colspan="1"
+      -->
+      <td colspan="500">
+        <div class="container">
+          <div class="selectionInfo">
+            ${this.numSelected
+              ? html`<span>${numSelectedLabel}</span>`
+              : nothing}
+          </div>
+          <div class="actionButtons">
+            <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+            <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
+            <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+          </div>
+        </div>
+      </td>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-action-bar': GrChangeListActionBar;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
new file mode 100644
index 0000000..df68f5f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  query,
+  queryAndAssert,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import './gr-change-list-action-bar';
+import type {GrChangeListActionBar} from './gr-change-list-action-bar';
+
+const change1 = {...createChange(), _number: 1 as NumericChangeId, actions: {}};
+const change2 = {...createChange(), _number: 2 as NumericChangeId, actions: {}};
+
+suite('gr-change-list-action-bar tests', () => {
+  let element: GrChangeListActionBar;
+  let model: BulkActionsModel;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChangeNums$, selectedChangeNums =>
+      selectedChangeNums.includes(change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    model.sync([change1, change2]);
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-action-bar></gr-change-list-action-bar>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-action-bar')!;
+    await element.updateComplete;
+  });
+
+  test('renders action bar', async () => {
+    await selectChange(change1);
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <td></td>
+      <td><input type="checkbox" /></td>
+      <td>
+        <div class="container">
+          <div class="selectionInfo">
+            <span>1 change selected</span>
+          </div>
+          <div class="actionButtons">
+            <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+            <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
+            <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+          </div>
+        </div>
+      </td>
+    `);
+  });
+
+  test('label reflects number of selected changes', async () => {
+    // zero case
+    let numSelectedLabel = query<HTMLSpanElement>(
+      element,
+      '.selectionInfo span'
+    );
+    assert.isUndefined(numSelectedLabel);
+
+    // single case
+    await selectChange(change1);
+    numSelectedLabel = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.selectionInfo span'
+    );
+    assert.equal(numSelectedLabel.innerText, '1 change selected');
+
+    // plural case
+    await selectChange(change2);
+
+    numSelectedLabel = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.selectionInfo span'
+    );
+    assert.equal(numSelectedLabel.innerText, '2 changes selected');
+  });
+
+  test('checkbox matches partial and fully selected state', async () => {
+    // zero case
+    let checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+    assert.isFalse(checkbox.checked);
+    assert.isFalse(checkbox.indeterminate);
+
+    // partial case
+    await selectChange(change1);
+    checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+    assert.isTrue(checkbox.indeterminate);
+
+    // plural case
+    await selectChange(change2);
+
+    checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+    assert.isFalse(checkbox.indeterminate);
+    assert.isTrue(checkbox.checked);
+  });
+
+  test('clicking checkbox clears selection', async () => {
+    await selectChange(change1);
+    await selectChange(change2);
+    let selectedChangeNums = await waitUntilObserved(
+      model.selectedChangeNums$,
+      s => s.length === 2
+    );
+    assert.sameMembers(selectedChangeNums, [change1._number, change2._number]);
+
+    const checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+    checkbox.click();
+
+    selectedChangeNums = await waitUntilObserved(
+      model.selectedChangeNums$,
+      s => s.length === 0
+    );
+    assert.isEmpty(selectedChangeNums);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
new file mode 100644
index 0000000..eb5e6a8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {customElement, state, query} from 'lit/decorators';
+import {LitElement, html, css} from 'lit';
+import {resolve} from '../../../models/dependency';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {NumericChangeId, ChangeInfo, ChangeStatus} from '../../../api/rest-api';
+import {subscribe} from '../../lit/subscription-controller';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {ProgressStatus} from '../../../constants/constants';
+import '../../shared/gr-dialog/gr-dialog';
+import {fireAlert, fireReload} from '../../../utils/event-util';
+
+@customElement('gr-change-list-bulk-abandon-flow')
+export class GrChangeListBulkAbandonFlow extends LitElement {
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  @state() selectedChanges: ChangeInfo[] = [];
+
+  @state() progress: Map<NumericChangeId, ProgressStatus> = new Map();
+
+  @query('#actionOverlay') actionOverlay!: GrOverlay;
+
+  static override get styles() {
+    return [
+      css`
+        section {
+          padding: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => (this.selectedChanges = selectedChanges)
+    );
+  }
+
+  override render() {
+    return html`
+      <gr-button
+        id="abandon"
+        flatten
+        .disabled=${!this.isEnabled()}
+        @click=${() => this.actionOverlay.open()}
+        >Abandon</gr-button
+      >
+      <gr-overlay id="actionOverlay" with-backdrop="">
+        <gr-dialog
+          .disableCancel=${!this.isCancelEnabled()}
+          .disabled=${!this.isConfirmEnabled()}
+          @confirm=${() => this.handleConfirm()}
+          @cancel=${() => this.handleClose()}
+          .cancelLabel=${'Close'}
+        >
+          <div slot="header">
+            ${this.selectedChanges.length} changes to abandon
+          </div>
+          <div slot="main">
+            <table>
+              <thead>
+                <tr>
+                  <th>Subject</th>
+                  <th>Status</th>
+                </tr>
+              </thead>
+              <tbody>
+                ${this.selectedChanges.map(
+                  change => html`
+                    <tr>
+                      <td>Change: ${change.subject}</td>
+                      <td id="status">
+                        Status: ${this.getStatus(change._number)}
+                      </td>
+                    </tr>
+                  `
+                )}
+              </tbody>
+            </table>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private getStatus(changeNum: NumericChangeId) {
+    return this.progress.has(changeNum)
+      ? this.progress.get(changeNum)
+      : ProgressStatus.NOT_STARTED;
+  }
+
+  private isEnabled() {
+    return this.selectedChanges.every(
+      change =>
+        !!change.actions?.abandon || change.status === ChangeStatus.ABANDONED
+    );
+  }
+
+  private isConfirmEnabled() {
+    // Action is allowed if none of the changes have any bulk action performed
+    // on them. In case an error happens then we keep the button disabled.
+    for (const status of this.progress.values()) {
+      if (status !== ProgressStatus.NOT_STARTED) return false;
+    }
+    return true;
+  }
+
+  private isCancelEnabled() {
+    for (const status of this.progress.values()) {
+      if (status === ProgressStatus.RUNNING) return false;
+    }
+    return true;
+  }
+
+  private handleConfirm() {
+    this.progress.clear();
+    for (const change of this.selectedChanges) {
+      this.progress.set(change._number, ProgressStatus.RUNNING);
+    }
+    this.requestUpdate();
+    const errFn = (changeNum: NumericChangeId) => {
+      throw new Error(`request for ${changeNum} failed`);
+    };
+    const promises = this.getBulkActionsModel().abandonChanges('', errFn);
+    for (let index = 0; index < promises.length; index++) {
+      const changeNum = this.selectedChanges[index]._number;
+      promises[index]
+        .then(() => {
+          this.progress.set(changeNum, ProgressStatus.SUCCESSFUL);
+          this.requestUpdate();
+        })
+        .catch(() => {
+          this.progress.set(changeNum, ProgressStatus.FAILED);
+          this.requestUpdate();
+        });
+    }
+  }
+
+  private handleClose() {
+    this.actionOverlay.close();
+    fireAlert(this, 'Reloading page..');
+    fireReload(this, true);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-bulk-abandon-flow': GrChangeListBulkAbandonFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
new file mode 100644
index 0000000..9c7523e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -0,0 +1,299 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {createChange} from '../../../test/test-data-generators';
+import {
+  NumericChangeId,
+  ChangeInfo,
+  ChangeStatus,
+  HttpMethod,
+  PatchSetNum,
+} from '../../../api/rest-api';
+import {GrChangeListBulkAbandonFlow} from './gr-change-list-bulk-abandon-flow';
+import '../../../test/common-test-setup-karma';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+  LoadingState,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import './gr-change-list-bulk-abandon-flow';
+import {fixture, waitUntil} from '@open-wc/testing-helpers';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {html} from 'lit';
+import {getAppContext} from '../../../services/app-context';
+import {
+  waitUntilObserved,
+  stubRestApi,
+  queryAndAssert,
+  mockPromise,
+  query,
+} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {ProgressStatus} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+import {ErrorCallback} from '../../../api/rest';
+
+const change1: ChangeInfo = {...createChange(), _number: 1 as NumericChangeId};
+const change2: ChangeInfo = {...createChange(), _number: 2 as NumericChangeId};
+
+suite('gr-change-list-bulk-abandon-flow tests', () => {
+  let element: GrChangeListBulkAbandonFlow;
+  let model: BulkActionsModel;
+  let getChangesStub: sinon.SinonStub;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChangeNums$, selectedChangeNums =>
+      selectedChangeNums.includes(change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    getChangesStub = stubRestApi('getDetailedChangesWithActions');
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-bulk-abandon-flow></gr-change-list-bulk-abandon-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-bulk-abandon-flow')!;
+    await element.updateComplete;
+  });
+
+  test('button state updates as changes are updated', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    await flush();
+    // await waitUntil(() => element.selectedChanges.length > 0);
+    assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
+
+    changes.push({...change2, actions: {}});
+    getChangesStub.restore();
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.isTrue(queryAndAssert<GrButton>(element, '#abandon').disabled);
+  });
+
+  test('abandon button is enabled if change is already abandoned', async () => {
+    const changes: ChangeInfo[] = [
+      {...change1, actions: {}, status: ChangeStatus.ABANDONED},
+    ];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    await flush();
+    assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
+
+    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+
+    await waitUntil(
+      () =>
+        queryAndAssert<HTMLTableDataCellElement>(
+          element,
+          '#status'
+        ).innerText.trim() === `Status: ${ProgressStatus.SUCCESSFUL}`
+    );
+  });
+
+  test('progress updates as request is resolved', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.NOT_STARTED}`
+    );
+
+    const executeChangeAction = mockPromise<Response>();
+    stubRestApi('executeChangeAction').returns(executeChangeAction);
+
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.RUNNING}`
+    );
+
+    executeChangeAction.resolve({...new Response(), status: 200});
+    await waitUntil(
+      () =>
+        element.progress.get(1 as NumericChangeId) === ProgressStatus.SUCCESSFUL
+    );
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.SUCCESSFUL}`
+    );
+  });
+
+  test('failures are reflected to the progress dialog', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.NOT_STARTED}`
+    );
+
+    stubRestApi('executeChangeAction').callsFake(
+      (
+        _changeNum: NumericChangeId,
+        _method: HttpMethod | undefined,
+        _endpoint: string,
+        _patchNum?: PatchSetNum,
+        _payload?: RequestPayload,
+        errFn?: ErrorCallback
+      ) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+
+    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.RUNNING}`
+    );
+
+    await waitUntil(
+      () => element.progress.get(1 as NumericChangeId) === ProgressStatus.FAILED
+    );
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.FAILED}`
+    );
+  });
+
+  test('closing dialog triggers a reload', async () => {
+    const changes: ChangeInfo[] = [
+      {...change1, actions: {abandon: {}}},
+      {...change2, actions: {abandon: {}}},
+    ];
+    getChangesStub.returns(changes);
+
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+
+    stubRestApi('executeChangeAction').callsFake(
+      (
+        _changeNum: NumericChangeId,
+        _method: HttpMethod | undefined,
+        _endpoint: string,
+        _patchNum?: PatchSetNum,
+        _payload?: RequestPayload,
+        errFn?: ErrorCallback
+      ) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await selectChange(change2);
+    await element.updateComplete;
+
+    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+
+    await waitUntil(
+      () => element.progress.get(2 as NumericChangeId) === ProgressStatus.FAILED
+    );
+
+    assert.isFalse(fireStub.called);
+
+    tap(queryAndAssert(query(element, 'gr-dialog'), '#cancel'));
+
+    await waitUntil(() => fireStub.called);
+    assert.equal(fireStub.lastCall.args[0].type, 'reload');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
new file mode 100644
index 0000000..77c9382
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -0,0 +1,384 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {customElement, query, state} from 'lit/decorators';
+import {LitElement, html, css, nothing} from 'lit';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {resolve} from '../../../models/dependency';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {ChangeInfo, AccountInfo, NumericChangeId} from '../../../api/rest-api';
+import {
+  getTriggerVotes,
+  computeLabels,
+  computeOrderedLabelValues,
+  mergeLabelInfoMaps,
+  getDefaultValue,
+  mergeLabelMaps,
+  Label,
+  StandardLabels,
+} from '../../../utils/label-util';
+import {getAppContext} from '../../../services/app-context';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {queryAndAssert} from '../../../utils/common-util';
+import '@polymer/iron-icon/iron-icon';
+import {
+  LabelNameToValuesMap,
+  ReviewInput,
+  LabelNameToValueMap,
+} from '../../../types/common';
+import {GrLabelScoreRow} from '../../change/gr-label-score-row/gr-label-score-row';
+import {ProgressStatus} from '../../../constants/constants';
+import {fireAlert, fireReload} from '../../../utils/event-util';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../change/gr-label-score-row/gr-label-score-row';
+import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {allSettled} from '../../../utils/async-util';
+import {pluralize} from '../../../utils/string-util';
+
+@customElement('gr-change-list-bulk-vote-flow')
+export class GrChangeListBulkVoteFlow extends LitElement {
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  @state() selectedChanges: ChangeInfo[] = [];
+
+  @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
+
+  @query('#actionOverlay') actionOverlay!: GrOverlay;
+
+  @state() account?: AccountInfo;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        gr-dialog {
+          width: 840px;
+        }
+        .scoresTable {
+          display: table;
+        }
+        .scoresTable.newSubmitRequirements {
+          table-layout: fixed;
+        }
+        gr-label-score-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        gr-label-score-row {
+          display: table-row;
+        }
+        /* TODO(dhruvsri): Consider using flex column with gap */
+        .scoresTable:not(:first-of-type) {
+          margin-top: var(--spacing-m);
+        }
+        .vote-type {
+          margin-bottom: var(--spacing-s);
+          margin-top: 0;
+          display: table-caption;
+        }
+        .main-heading {
+          margin-bottom: var(--spacing-m);
+          font-weight: var(--font-weight-h2);
+        }
+        .error-container {
+          background-color: var(--red-50);
+          margin-top: var(--spacing-l);
+        }
+        .error-container iron-icon {
+          padding: 10px var(--spacing-xl);
+          color: var(--red-700);
+          --iron-icon-height: 20px;
+          --iron-icon-width: 20px;
+        }
+        .error-container span {
+          position: relative;
+          top: 1px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+        this.resetFlow();
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      account => (this.account = account)
+    );
+  }
+
+  override render() {
+    const permittedLabels = this.computePermittedLabels();
+    const triggerLabels = this.computeCommonTriggerLabels(permittedLabels);
+    const nonTriggerLabels = this.computeCommonPermittedLabels(
+      permittedLabels
+    ).filter(label => !triggerLabels.some(l => l.name === label.name));
+    return html`
+      <gr-button
+        .disabled=${triggerLabels.length === 0 && nonTriggerLabels.length === 0}
+        id="voteFlowButton"
+        flatten
+        @click=${() => this.actionOverlay.open()}
+        >Vote</gr-button
+      >
+      <gr-overlay id="actionOverlay" with-backdrop="">
+        <gr-dialog
+          .disableCancel=${!this.isCancelEnabled()}
+          .disabled=${!this.isConfirmEnabled()}
+          ?loading=${this.isLoading()}
+          .loadingLabel=${'Voting in progress...'}
+          @confirm=${() => this.handleConfirm()}
+          @cancel=${() => this.handleClose()}
+          .confirmLabel=${'Vote'}
+          .cancelLabel=${'Cancel'}
+        >
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            ${this.renderLabels(
+              nonTriggerLabels,
+              'Submit requirements votes',
+              permittedLabels
+            )}
+            ${this.renderLabels(
+              triggerLabels,
+              'Trigger Votes',
+              permittedLabels
+            )}
+            ${this.renderErrors()}
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChange) !== ProgressStatus.FAILED) {
+      return nothing;
+    }
+    return html`
+      <div class="error-container">
+        <iron-icon icon="gr-icons:error"></iron-icon>
+        <span>
+          <!-- prettier-ignore -->
+          Failed to vote on ${pluralize(
+            Array.from(this.progressByChange.values()).filter(
+              status => status === ProgressStatus.FAILED
+            ).length,
+            'change'
+          )}
+        </span>
+      </div>
+    `;
+  }
+
+  private renderLabels(
+    labels: Label[],
+    heading: string,
+    permittedLabels?: LabelNameToValuesMap
+  ) {
+    return html` <div class="scoresTable newSubmitRequirements">
+      <h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
+      ${labels
+        .filter(
+          label =>
+            permittedLabels?.[label.name] &&
+            permittedLabels?.[label.name].length > 0
+        )
+        .map(
+          label => html`<gr-label-score-row
+            .label=${label}
+            .name=${label.name}
+            .labels=${this.computeLabelNameToInfoMap()}
+            .permittedLabels=${permittedLabels}
+            .orderedLabelValues=${computeOrderedLabelValues(permittedLabels)}
+          ></gr-label-score-row>`
+        )}
+    </div>`;
+  }
+
+  private resetFlow() {
+    this.progressByChange = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.NOT_STARTED,
+      ])
+    );
+  }
+
+  private isLoading() {
+    return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
+  }
+
+  private isConfirmEnabled() {
+    // Action is allowed if none of the changes have any bulk action performed
+    // on them. In case an error happens then we keep the button disabled.
+    return (
+      getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED
+    );
+  }
+
+  private isCancelEnabled() {
+    return getOverallStatus(this.progressByChange) !== ProgressStatus.RUNNING;
+  }
+
+  private handleClose() {
+    this.actionOverlay.close();
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
+      return;
+    fireReload(this, true);
+  }
+
+  private async handleConfirm() {
+    this.progressByChange.clear();
+    this.reportingService.reportInteraction('bulk-action', {
+      type: 'vote',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    const reviewInput: ReviewInput = {
+      labels: this.getLabelValues(
+        this.computeCommonPermittedLabels(this.computePermittedLabels())
+      ),
+    };
+    for (const change of this.selectedChanges) {
+      this.progressByChange.set(change._number, ProgressStatus.RUNNING);
+    }
+    this.requestUpdate();
+    const promises = this.getBulkActionsModel().voteChanges(reviewInput);
+
+    await allSettled(
+      promises.map((promise, index) => {
+        const changeNum = this.selectedChanges[index]._number;
+        return promise
+          .then(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
+          })
+          .catch(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.FAILED);
+          })
+          .finally(() => {
+            this.requestUpdate();
+            if (
+              getOverallStatus(this.progressByChange) ===
+              ProgressStatus.SUCCESSFUL
+            ) {
+              fireAlert(this, 'Votes added');
+              this.handleClose();
+            }
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'vote',
+        count: Array.from(this.progressByChange.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    }
+  }
+
+  // private but used in tests
+  getLabelValues(commonPermittedLabels: Label[]): LabelNameToValueMap {
+    const labels: LabelNameToValueMap = {};
+
+    for (const label of commonPermittedLabels) {
+      const selectorEl = queryAndAssert<GrLabelScoreRow>(
+        this,
+        `gr-label-score-row[name="${label.name}"]`
+      );
+      if (!selectorEl?.selectedItem) continue;
+
+      const selectedVal =
+        typeof selectorEl.selectedValue === 'string'
+          ? Number(selectorEl.selectedValue)
+          : selectorEl.selectedValue;
+
+      if (selectedVal === undefined) continue;
+
+      const defValNum = getDefaultValue(
+        this.selectedChanges[0].labels,
+        label.name
+      );
+      if (selectedVal !== defValNum) {
+        labels[label.name] = selectedVal;
+      }
+    }
+    return labels;
+  }
+
+  // private but used in tests
+  computePermittedLabels() {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return {};
+
+    const permittedLabels = this.selectedChanges
+      .map(changes => changes.permitted_labels)
+      .reduce(mergeLabelMaps);
+    // TODO: show a warning to the user that Code Review cannot be voted upon
+    if (permittedLabels?.[StandardLabels.CODE_REVIEW]) {
+      delete permittedLabels[StandardLabels.CODE_REVIEW];
+    }
+    return permittedLabels;
+  }
+
+  private computeLabelNameToInfoMap() {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return {};
+
+    return this.selectedChanges
+      .map(changes => changes.labels)
+      .reduce(mergeLabelInfoMaps);
+  }
+
+  // private but used in tests
+  computeCommonTriggerLabels(permittedLabels?: LabelNameToValuesMap) {
+    if (this.selectedChanges.length === 0) return [];
+    const triggerVotes = this.selectedChanges
+      .map(change => getTriggerVotes(change))
+      .reduce((prev, current) =>
+        current.filter(label => prev.some(l => l === label))
+      );
+    return this.computeCommonPermittedLabels(permittedLabels).filter(label =>
+      triggerVotes.includes(label.name)
+    );
+  }
+
+  // private but used in tests
+  computeCommonPermittedLabels(permittedLabels?: LabelNameToValuesMap) {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return [];
+    return this.selectedChanges
+      .map(change => computeLabels(this.account, change))
+      .reduce((prev, current) =>
+        current.filter(label => prev.some(l => l.name === label.name))
+      )
+      .filter(
+        label =>
+          permittedLabels?.[label.name] &&
+          permittedLabels?.[label.name].length > 0
+      );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-bulk-vote-flow': GrChangeListBulkVoteFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
new file mode 100644
index 0000000..e2cbaf0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -0,0 +1,595 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../../test/common-test-setup-karma';
+import {GrChangeListBulkVoteFlow} from './gr-change-list-bulk-vote-flow';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+  LoadingState,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {
+  waitUntilObserved,
+  stubRestApi,
+  queryAndAssert,
+  query,
+  mockPromise,
+  queryAll,
+  stubReporting,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
+import {fixture, waitUntil} from '@open-wc/testing-helpers';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {html} from 'lit';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  createChange,
+  createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import './gr-change-list-bulk-vote-flow';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {ProgressStatus} from '../../../constants/constants';
+import {StandardLabels} from '../../../utils/label-util';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+const change1: ChangeInfo = {
+  ...createChange(),
+  _number: 1 as NumericChangeId,
+  permitted_labels: {
+    [StandardLabels.CODE_REVIEW]: ['-1', '0', '+1', '+2'],
+    A: ['-1', '0', '+1', '+2'],
+    B: ['-1', '0'],
+    C: ['-1', '0'],
+    change1OnlyLabelD: ['0'], // Does not exist on change2
+    change1OnlyTriggerLabelE: ['0'], // Does not exist on change2
+  },
+  labels: {
+    [StandardLabels.CODE_REVIEW]: {value: null} as LabelInfo,
+    A: {value: null} as LabelInfo,
+    B: {value: null} as LabelInfo,
+    C: {value: null} as LabelInfo,
+    change1OnlyLabelD: {value: null} as LabelInfo,
+    change1OnlyTriggerLabelE: {value: null} as LabelInfo,
+  },
+  submit_requirements: [
+    createSubmitRequirementResultInfo(
+      `label:${StandardLabels.CODE_REVIEW}=MAX`
+    ),
+    createSubmitRequirementResultInfo('label:A=MAX'),
+    createSubmitRequirementResultInfo('label:B=MAX'),
+    createSubmitRequirementResultInfo('label:C=MAX'),
+    createSubmitRequirementResultInfo('label:change1OnlyLabelD=MAX'),
+  ],
+};
+const change2: ChangeInfo = {
+  ...createChange(),
+  _number: 2 as NumericChangeId,
+  permitted_labels: {
+    [StandardLabels.CODE_REVIEW]: ['-1', '0', '+1', '+2'],
+    A: ['-1', '0', '+1', '+2'], // Intersects fully with change1
+    B: ['0', ' +1'], // Intersects with change1 on 0
+    C: ['+1', '+2'], // Does not intersect with change1 at all
+  },
+  labels: {
+    [StandardLabels.CODE_REVIEW]: {value: null} as LabelInfo,
+    A: {value: null} as LabelInfo,
+    B: {value: null} as LabelInfo,
+    C: {value: null} as LabelInfo,
+  },
+  submit_requirements: [
+    createSubmitRequirementResultInfo(
+      `label:${StandardLabels.CODE_REVIEW}=MAX`
+    ),
+    createSubmitRequirementResultInfo('label:A=MAX'),
+    createSubmitRequirementResultInfo('label:B=MAX'),
+    createSubmitRequirementResultInfo('label:C=MAX'),
+  ],
+};
+
+suite('gr-change-list-bulk-vote-flow tests', () => {
+  let element: GrChangeListBulkVoteFlow;
+  let model: BulkActionsModel;
+  let dispatchEventStub: sinon.SinonStub;
+  let getChangesStub: SinonStubbedMember<
+    RestApiService['getDetailedChangesWithActions']
+  >;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChangeNums$, selectedChangeNums =>
+      selectedChangeNums.includes(change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    getChangesStub = stubRestApi('getDetailedChangesWithActions');
+    reportingStub = stubReporting('reportInteraction');
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-bulk-vote-flow')!;
+    await element.updateComplete;
+    dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+  });
+
+  test('renders', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `<gr-button
+        aria-disabled="false"
+        flatten=""
+        id="voteFlowButton"
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <gr-overlay
+        aria-hidden="true"
+        id="actionOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay> `);
+  });
+
+  test('renders with errors', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.FAILED
+    );
+
+    expect(element).shadowDom.to.equal(/* HTML */ `<gr-button
+        aria-disabled="false"
+        flatten=""
+        id="voteFlowButton"
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <gr-overlay
+        aria-hidden="true"
+        id="actionOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </div>
+            <div class="error-container">
+              <iron-icon icon="gr-icons:error"> </iron-icon>
+              <span> Failed to vote on 1 change </span>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay> `);
+  });
+
+  test('button state updates as changes are updated', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    await flush();
+
+    assert.isFalse(
+      queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
+    );
+
+    // No common label with change1 so button is disabled
+    change2.labels = {
+      x: {value: null} as LabelInfo,
+      y: {value: null} as LabelInfo,
+      z: {value: null} as LabelInfo,
+    };
+    change2.submit_requirements = [
+      createSubmitRequirementResultInfo('label:x=MAX'),
+      createSubmitRequirementResultInfo('label:y=MAX'),
+      createSubmitRequirementResultInfo('label:z=MAX'),
+    ];
+    changes.push({...change2});
+    getChangesStub.restore();
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
+    );
+  });
+
+  test('progress updates as request is resolved', async () => {
+    const changes: ChangeInfo[] = [{...change1}];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    const saveChangeReview = mockPromise<Response>();
+    stubRestApi('saveChangeReview').returns(saveChangeReview);
+
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    const scores = queryAll(element, 'gr-label-score-row');
+    queryAndAssert<GrButton>(scores[0], 'gr-button[data-value="+1"]').click();
+    queryAndAssert<GrButton>(scores[1], 'gr-button[data-value="-1"]').click();
+
+    await element.updateComplete;
+
+    assert.deepEqual(
+      element.getLabelValues(
+        element.computeCommonPermittedLabels(element.computePermittedLabels())
+      ),
+      {
+        A: 1,
+        B: -1,
+      }
+    );
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.deepEqual(reportingStub.lastCall.args[1], {
+      type: 'vote',
+      selectedChangeCount: 1,
+    });
+
+    assert.equal(
+      element.progressByChange.get(1 as NumericChangeId),
+      ProgressStatus.RUNNING
+    );
+
+    saveChangeReview.resolve({...new Response(), status: 200});
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.SUCCESSFUL
+    );
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      element.progressByChange.get(1 as NumericChangeId),
+      ProgressStatus.SUCCESSFUL
+    );
+
+    // reload event is fired automatically when all requests succeed
+    assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
+    assert.equal(
+      dispatchEventStub.firstCall.args[0].detail.message,
+      'Votes added'
+    );
+  });
+
+  suite('closing dialog triggers reloads', () => {
+    test('closing dialog triggers a reload', async () => {
+      const changes: ChangeInfo[] = [change1, change2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      stubRestApi('saveChangeReview').callsFake(
+        (_changeNum, _patchNum, _review, errFn) =>
+          Promise.resolve(new Response()).then(res => {
+            errFn && errFn();
+            return res;
+          })
+      );
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(change2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+      await waitUntil(
+        () =>
+          element.progressByChange.get(2 as NumericChangeId) ===
+          ProgressStatus.FAILED
+      );
+
+      // Dialog does not autoclose and fire reload event if some request fails
+      assert.isFalse(dispatchEventStub.called);
+
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'vote',
+          count: 2,
+        },
+      ]);
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+      await waitUntil(() => dispatchEventStub.called);
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
+    });
+
+    test('closing dialog does not trigger reload if no request made', async () => {
+      const changes: ChangeInfo[] = [change1, change2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(change2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+      assert.isFalse(dispatchEventStub.called);
+    });
+  });
+
+  test('computePermittedLabels', async () => {
+    // {} if no change is selected
+    assert.deepEqual(element.computePermittedLabels(), {});
+
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.deepEqual(element.computePermittedLabels(), {
+      A: ['-1', '0', '+1', '+2'],
+      B: ['-1', '0'],
+      C: ['-1', '0'],
+      change1OnlyLabelD: ['0'],
+      change1OnlyTriggerLabelE: ['0'],
+    });
+
+    changes.push(change2);
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.deepEqual(element.computePermittedLabels(), {
+      A: ['-1', '0', '+1', '+2'],
+      B: ['0'],
+      C: [],
+    });
+  });
+
+  test('computeCommonPermittedLabels', async () => {
+    const createChangeWithLabels = (
+      num: NumericChangeId,
+      labelNames: string[],
+      triggerLabels?: string[]
+    ) => {
+      const change = createChange();
+      change._number = num;
+      change.submit_requirements = [];
+      change.labels = {};
+      change.permitted_labels = {};
+      for (const label of labelNames) {
+        change.labels[label] = {value: null} as LabelInfo;
+        if (!triggerLabels?.includes(label)) {
+          change.submit_requirements.push(
+            createSubmitRequirementResultInfo(`label:${label}=MAX`)
+          );
+        }
+        change.permitted_labels[label] = ['0'];
+      }
+      return change;
+    };
+
+    const changes: ChangeInfo[] = [
+      createChangeWithLabels(
+        1 as NumericChangeId,
+        ['a', 'triggerLabelB', 'c'],
+        ['triggerLabelB']
+      ),
+      createChangeWithLabels(
+        2 as NumericChangeId,
+        ['triggerLabelB', 'c', 'd'],
+        ['triggerLabelB']
+      ),
+      createChangeWithLabels(3 as NumericChangeId, ['c', 'd', 'e']),
+      createChangeWithLabels(4 as NumericChangeId, ['x', 'y', 'z']),
+    ];
+    // Labels for each change are [a,b,c] [b,c,d] [c,d,e] [x,y,z]
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(
+      createChangeWithLabels(1 as NumericChangeId, ['a', 'triggerLabelB', 'c'])
+    );
+    await element.updateComplete;
+
+    // Code-Review is not a common permitted label
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [
+        {name: 'a', value: null},
+        {name: 'c', value: null},
+        {name: 'triggerLabelB', value: null},
+      ]
+    );
+
+    await selectChange(
+      createChangeWithLabels(2 as NumericChangeId, ['triggerLabelB', 'c', 'd'])
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      [{name: 'triggerLabelB', value: null}]
+    );
+
+    await element.updateComplete;
+
+    // Intersection of [CR, 'a', 'triggerLabelB', 'c']
+    // [CR, 'triggerLabelB', 'c', 'd'] is [triggerLabelB,c]
+    // Code-Review is not a common permitted label
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [
+        {name: 'c', value: null},
+        {name: 'triggerLabelB', value: null},
+      ]
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      [{name: 'triggerLabelB', value: null}]
+    );
+
+    await selectChange(
+      createChangeWithLabels(3 as NumericChangeId, ['c', 'd', 'e'])
+    );
+
+    await element.updateComplete;
+
+    // Intersection of [a,triggerLabelB,c] [triggerLabelB,c,d] [c,d,e] is [c]
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [{name: 'c', value: null}]
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      []
+    );
+
+    await selectChange(
+      createChangeWithLabels(4 as NumericChangeId, ['x', 'y', 'z'])
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      []
+    );
+
+    await element.updateComplete;
+
+    // Intersection of [a,triggerLabelB,c] [triggerLabelB,c,d] [c,d,e] [x,y,z]
+    // is []
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      []
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
index 1b8921e..09b5cc9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -74,7 +74,7 @@
   override render() {
     return html`<div
       class="container ${this.computeClass()}"
-      title="${ifDefined(this.computeLabelTitle())}"
+      title=${ifDefined(this.computeLabelTitle())}
     >
       ${this.renderContent()}
     </div>`;
@@ -105,14 +105,14 @@
       if (votes.length > 0) {
         const bestVote = votes[0];
         return html`<gr-vote-chip
-          .vote="${bestVote}"
-          .label="${labelInfo}"
+          .vote=${bestVote}
+          .label=${labelInfo}
           tooltip-with-who-voted
         ></gr-vote-chip>`;
       }
     }
     if (isQuickLabelInfo(labelInfo)) {
-      return html`<gr-vote-chip .label="${labelInfo}"></gr-vote-chip>`;
+      return html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`;
     }
     return;
   }
@@ -144,8 +144,8 @@
       return this.renderStatusIcon(requirement.status);
     } else {
       return html`<gr-vote-chip
-        .vote="${worstVote}"
-        .label="${labelInfo}"
+        .vote=${worstVote}
+        .label=${labelInfo}
         tooltip-with-who-voted
       ></gr-vote-chip>`;
     }
@@ -153,10 +153,7 @@
 
   private renderStatusIcon(status: SubmitRequirementStatus) {
     const icon = iconForStatus(status ?? SubmitRequirementStatus.ERROR);
-    return html`<iron-icon
-      class="${icon}"
-      icon="gr-icons:${icon}"
-    ></iron-icon>`;
+    return html`<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>`;
   }
 
   private computeClass(): string {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
index f39e57c..96240de 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -46,7 +46,6 @@
       name: StandardLabels.CODE_REVIEW,
       submittability_expression_result: {
         ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
       },
     };
     change = {
@@ -98,10 +97,7 @@
       ...createSubmitRequirementResultInfo(),
       name: StandardLabels.CODE_REVIEW,
       status: SubmitRequirementStatus.UNSATISFIED,
-      submittability_expression_result: {
-        ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
-      },
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
     change = {
       ...change,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
index 2272133..1f8bf51 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -122,10 +122,10 @@
   }
 
   renderState(icon: string, aggregation: string | TemplateResult) {
-    return html`<span class="${icon}" role="button" tabindex="0">
+    return html`<span class=${icon} role="button" tabindex="0">
       <gr-submit-requirement-dashboard-hovercard .change=${this.change}>
       </gr-submit-requirement-dashboard-hovercard>
-      <iron-icon class="${icon}" icon="gr-icons:${icon}" role="img"></iron-icon
+      <iron-icon class=${icon} icon="gr-icons:${icon}" role="img"></iron-icon
       >${aggregation}</span
     >`;
   }
@@ -142,10 +142,10 @@
     return html`<iron-icon
       icon="gr-icons:comment"
       class="commentIcon"
-      .title="${pluralize(
+      .title=${pluralize(
         this.change?.unresolved_comment_count,
         'unresolved comment'
-      )}"
+      )}
     ></iron-icon>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
index 9021e46..5cc4e6d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
@@ -42,10 +42,7 @@
       ...createSubmitRequirementResultInfo(),
       status: SubmitRequirementStatus.UNSATISFIED,
       description: 'Test Description',
-      submittability_expression_result: {
-        ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
-      },
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
     change = {
       ...createParsedChange(),
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
new file mode 100644
index 0000000..d7ff864
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,379 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, Hashtag} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+
+@customElement('gr-change-list-hashtag-flow')
+export class GrChangeListHashtagFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+
+  @state() private existingHashtagSuggestions: Hashtag[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingHashtags: Set<Hashtag> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--gray-300);
+        }
+        .chip.selected {
+          color: var(--blue-800);
+          background-color: var(--blue-50);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Hashtag</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${when(
+                this.selectedChanges.some(change => change.hashtags?.length),
+                () => this.renderExistingHashtagsMode(),
+                () => this.renderNoExistingHashtagsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingHashtagsMode() {
+    const hashtags = this.selectedChanges
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingHashtags.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const applyToAllDisabled = this.selectedExistingHashtags.size !== 1;
+    return html`
+      <div class="chips">
+        ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="apply-to-all-button"
+            flatten
+            ?disabled=${applyToAllDisabled}
+            @click=${this.applyHashtagToAll}
+            >Apply to all</gr-button
+          >
+          <gr-button
+            id="remove-hashtags-button"
+            flatten
+            ?disabled=${removeDisabled}
+            @click=${this.removeHashtags}
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingHashtagChip(name: Hashtag) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingHashtags.has(name),
+    };
+    return html`
+      <span
+        role="button"
+        aria-label=${name as string}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingHashtagSelected(name)}
+      >
+        ${name}
+      </span>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    if (this.overallProgress === ProgressStatus.RUNNING) {
+      return html`
+        <span class="loadingSpin"></span>
+        <span class="loadingText">${this.loadingText}</span>
+      `;
+    } else if (this.errorText !== undefined) {
+      return html`<div class="error">${this.errorText}</div>`;
+    }
+    return nothing;
+  }
+
+  private renderNoExistingHashtagsMode() {
+    const isCreateNewHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const isApplyHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      !this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <!--
+        The .query function needs to be bound to this because lit's autobind
+        seems to work only for @event handlers.
+        'this.getHashtagSuggestions.bind(this)' gets in trouble with our linter
+        even though the bind is necessary here, so an anonymous function is used
+        instead.
+      -->
+      <gr-autocomplete
+        .text=${this.hashtagToAdd}
+        .query=${(query: string) => this.getHashtagSuggestions(query)}
+        show-blue-focus-border
+        placeholder="Type hashtag name to create or filter hashtags"
+        @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+          (this.hashtagToAdd = e.detail.value)}
+      ></gr-autocomplete>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="create-new-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Creating hashtag...')}
+            .disabled=${isCreateNewHashtagDisabled}
+            >Create new hashtag</gr-button
+          >
+          <gr-button
+            id="apply-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Applying hashtag...')}
+            .disabled=${isApplyHashtagDisabled}
+            >Apply</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    if (this.isDropdownOpen) {
+      this.closeDropdown();
+    } else {
+      this.reset();
+      this.openDropdown();
+    }
+  }
+
+  private reset() {
+    this.hashtagToAdd = '' as Hashtag;
+    this.selectedExistingHashtags = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getHashtagSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
+      query
+    );
+    this.existingHashtagSuggestions = (suggestions ?? [])
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingHashtagSuggestions.map(hashtag => {
+      return {name: hashtag, value: hashtag};
+    });
+  }
+
+  private removeHashtags() {
+    this.loadingText = `Removing hashtag${
+      this.selectedExistingHashtags.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.hashtags &&
+            change.hashtags.some(hashtag =>
+              this.selectedExistingHashtags.has(hashtag)
+            )
+        )
+        .map(change =>
+          this.restApiService.setChangeHashtag(change._number, {
+            remove: Array.from(this.selectedExistingHashtags.values()),
+          })
+        )
+    );
+  }
+
+  private applyHashtagToAll() {
+    this.loadingText = 'Applying hashtag to all';
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: Array.from(this.selectedExistingHashtags.values()),
+        })
+      )
+    );
+  }
+
+  private addHashtag(loadingText: string) {
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: [this.hashtagToAdd],
+        })
+      )
+    );
+  }
+
+  private async trackPromises(promises: Promise<Hashtag[]>[]) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.closeDropdown();
+      // TODO: fire reload of dashboard
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      // TODO: when some are rejected, show error and Cancel button
+    }
+  }
+
+  private toggleExistingHashtagSelected(name: Hashtag) {
+    if (this.selectedExistingHashtags.has(name)) {
+      this.selectedExistingHashtags.delete(name);
+    } else {
+      this.selectedExistingHashtags.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-hashtag-flow': GrChangeListHashtagFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
new file mode 100644
index 0000000..6910ae2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,542 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-hashtag-flow';
+import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+
+suite('gr-change-list-hashtag-flow tests', () => {
+  let element: GrChangeListHashtagFlow;
+  let model: BulkActionsModel;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >Hashtag</gr-button
+        >
+        <iron-dropdown
+          aria-disabled="false"
+          aria-hidden="true"
+          style="outline: none; display: none;"
+          vertical-align="auto"
+          horizontal-align="auto"
+        >
+        </iron-dropdown>
+      `);
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('changes in existing hashtags', () => {
+    const changesWithHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        hashtags: ['hashtag1' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        hashtags: ['hashtag2' as Hashtag],
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithHashtags);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changesWithHashtags[0]);
+      await selectChange(changesWithHashtags[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <span role="button" aria-label="hashtag1" class="chip"
+                  >hashtag1</span
+                >
+                <span role="button" aria-label="hashtag2" class="chip"
+                  >hashtag2</span
+                >
+              </div>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="apply-to-all-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply to all</gr-button
+                  >
+                  <gr-button
+                    id="remove-hashtags-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Remove</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('remove single hashtag', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledOnce);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1']},
+      ]);
+    });
+
+    test('remove multiple hashtags', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtags...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+    });
+
+    test('can only apply a single hashtag', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies hashtag to all changes', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {add: ['hashtag1']},
+      ]);
+    });
+  });
+
+  suite('change have no existing hashtags', () => {
+    const changesWithNoHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithNoHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithNoHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoHashtags);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changesWithNoHashtags[0]);
+      await selectChange(changesWithNoHashtags[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders no-existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <gr-autocomplete
+                placeholder="Type hashtag name to create or filter hashtags"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="create-new-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Create new hashtag</gr-button
+                  >
+                  <gr-button
+                    id="apply-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('create new hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#create-new-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+
+    test('apply hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#create-new-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 028c396..b4ebd3c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-date-formatter/gr-date-formatter';
@@ -40,12 +40,9 @@
   ChangeInfo,
   ServerInfo,
   AccountInfo,
-  QuickLabelInfo,
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
-import {pluralize} from '../../../utils/string-util';
-import {showNewSubmitRequirements} from '../../../utils/label-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
@@ -53,7 +50,7 @@
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {ifDefined} from 'lit/directives/if-defined';
 import {KnownExperimentId} from '../../../services/flags/flags';
-import {WAITING} from '../../../constants/constants';
+import {ColumnNames, WAITING} from '../../../constants/constants';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
@@ -125,6 +122,18 @@
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => {
+        if (!this.change) return;
+        this.checked = selectedChangeNums.includes(this.change._number);
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
@@ -134,14 +143,6 @@
           'change-list-item-cell'
         );
       });
-    subscribe(
-      this,
-      this.getBulkActionsModel().selectedChangeNums$,
-      selectedChangeNums => {
-        if (!this.change) return;
-        this.checked = selectedChangeNums.includes(this.change!._number);
-      }
-    );
   }
 
   static override get styles() {
@@ -244,22 +245,6 @@
         .subject:hover .content {
           text-decoration: underline;
         }
-        .u-monospace {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-mono);
-          line-height: var(--line-height-mono);
-        }
-        .u-green,
-        .u-green iron-icon {
-          color: var(--positive-green-text-color);
-        }
-        .u-red,
-        .u-red iron-icon {
-          color: var(--negative-red-text-color);
-        }
-        .u-gray-background {
-          background-color: var(--table-header-background-color);
-        }
         .comma,
         .placeholder {
           color: var(--deemphasized-text-color);
@@ -310,9 +295,15 @@
     if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
     return html`
       <td class="cell selection">
+        <!--
+          The .checked property must be used rather than the attribute because
+          the attribute only controls the default checked state and does not
+          update the current checked state.
+          See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
+        -->
         <input
           type="checkbox"
-          ?checked=${this.checked}
+          .checked=${this.checked}
           @click=${() => this.handleChangeSelectionClick()}
         />
       </td>
@@ -334,20 +325,25 @@
 
     return html`
       <td class="cell number">
-        <a href="${changeUrl}">${this.change?._number}</a>
+        <a href=${changeUrl}>${this.change?._number}</a>
       </td>
     `;
   }
 
   private renderCellSubject(changeUrl: string) {
-    if (this.computeIsColumnHidden('Subject', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.SUBJECT,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
       <td class="cell subject">
         <a
-          title="${ifDefined(this.change?.subject)}"
-          href="${changeUrl}"
+          title=${ifDefined(this.change?.subject)}
+          href=${changeUrl}
           @click=${() => this.handleChangeClick()}
         >
           <div class="container">
@@ -361,7 +357,12 @@
   }
 
   private renderCellStatus() {
-    if (this.computeIsColumnHidden('Status', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html` <td class="cell status">${this.renderChangeStatus()}</td> `;
@@ -381,22 +382,33 @@
   }
 
   private renderCellOwner() {
-    if (this.computeIsColumnHidden('Owner', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.OWNER,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
       <td class="cell owner">
-        <gr-account-link
+        <gr-account-label
           highlightAttention
+          clickable
           .change=${this.change}
           .account=${this.change?.owner}
-        ></gr-account-link>
+        ></gr-account-label>
       </td>
     `;
   }
 
   private renderCellReviewers() {
-    if (this.computeIsColumnHidden('Reviewers', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REVIEWERS,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -406,7 +418,7 @@
             this.renderChangeReviewers(reviewer, index)
           )}
           ${this.computeAdditionalReviewersCount()
-            ? html`<span title="${this.computeAdditionalReviewersTitle()}"
+            ? html`<span title=${this.computeAdditionalReviewersTitle()}
                 >+${this.computeAdditionalReviewersCount()}</span
               >`
             : ''}
@@ -417,13 +429,14 @@
 
   private renderChangeReviewers(reviewer: AccountInfo, index: number) {
     return html`
-      <gr-account-link
+      <gr-account-label
+        clickable
         hideAvatar
         firstName
         highlightAttention
         .change=${this.change}
         .account=${reviewer}
-      ></gr-account-link
+      ></gr-account-label
       ><span ?hidden=${this.computeCommaHidden(index)} aria-hidden="true"
         >,
       </span>
@@ -447,18 +460,23 @@
   }
 
   private renderCellRepo() {
-    if (this.computeIsColumnHidden('Repo', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REPO,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
       <td class="cell repo">
-        <a class="fullRepo" href="${this.computeRepoUrl()}">
+        <a class="fullRepo" href=${this.computeRepoUrl()}>
           ${this.computeRepoDisplay()}
         </a>
         <a
           class="truncatedRepo"
-          href="${this.computeRepoUrl()}"
-          title="${this.computeRepoDisplay()}"
+          href=${this.computeRepoUrl()}
+          title=${this.computeRepoDisplay()}
         >
           ${this.computeTruncatedRepoDisplay()}
         </a>
@@ -467,12 +485,17 @@
   }
 
   private renderCellBranch() {
-    if (this.computeIsColumnHidden('Branch', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.BRANCH,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
       <td class="cell branch">
-        <a href="${this.computeRepoBranchURL()}"> ${this.change?.branch} </a>
+        <a href=${this.computeRepoBranchURL()}> ${this.change?.branch} </a>
         ${this.renderChangeBranch()}
       </td>
     `;
@@ -482,7 +505,7 @@
     if (!this.change?.topic) return;
 
     return html`
-      (<a href="${this.computeTopicURL()}"
+      (<a href=${this.computeTopicURL()}
         ><!--
       --><gr-limited-text .limit=${50} .text=${this.change.topic}>
         </gr-limited-text
@@ -542,7 +565,7 @@
 
     return html`
       <td class="cell size">
-        <gr-tooltip-content has-tooltip title="${this.computeSizeTooltip()}">
+        <gr-tooltip-content has-tooltip title=${this.computeSizeTooltip()}>
           ${this.renderChangeSize()}
         </gr-tooltip-content>
       </td>
@@ -559,7 +582,12 @@
   }
 
   private renderCellRequirements() {
-    if (this.computeIsColumnHidden(' Status ', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS2,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -571,38 +599,19 @@
   }
 
   private renderChangeLabels(labelName: string) {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return html` <td class="cell label requirement">
-        <gr-change-list-column-requirement
-          .change=${this.change}
-          .labelName=${labelName}
-        >
-        </gr-change-list-column-requirement>
-      </td>`;
-    }
-    return html`
-      <td
-        title="${this.computeLabelTitle(labelName)}"
-        class="${this.computeLabelClass(labelName)}"
+    return html` <td class="cell label requirement">
+      <gr-change-list-column-requirement
+        .change=${this.change}
+        .labelName=${labelName}
       >
-        ${this.renderChangeHasLabelIcon(labelName)}
-      </td>
-    `;
-  }
-
-  private renderChangeHasLabelIcon(labelName: string) {
-    if (this.computeLabelIcon(labelName) === '')
-      return html`<span>${this.computeLabelValue(labelName)}</span>`;
-
-    return html`
-      <iron-icon icon=${this.computeLabelIcon(labelName)}></iron-icon>
-    `;
+      </gr-change-list-column-requirement>
+    </td>`;
   }
 
   private renderChangePluginEndpoint(pluginEndpointName: string) {
     return html`
       <td class="cell endpoint">
-        <gr-endpoint-decorator name="${pluginEndpointName}">
+        <gr-endpoint-decorator name=${pluginEndpointName}>
           <gr-endpoint-param name="change" .value=${this.change}>
           </gr-endpoint-param>
         </gr-endpoint-decorator>
@@ -614,9 +623,9 @@
     assertIsDefined(this.change, 'change');
     this.checked = !this.checked;
     if (this.checked)
-      this.getBulkActionsModel().addSelectedChangeNum(this.change!._number);
+      this.getBulkActionsModel().addSelectedChangeNum(this.change._number);
     else
-      this.getBulkActionsModel().removeSelectedChangeNum(this.change!._number);
+      this.getBulkActionsModel().removeSelectedChangeNum(this.change._number);
   }
 
   private changeStatuses() {
@@ -629,118 +638,6 @@
     return GerritNav.getUrlForChange(this.change);
   }
 
-  // private but used in test
-  computeLabelTitle(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    if (!label || category === LabelCategory.NOT_APPLICABLE) {
-      return 'Label not applicable';
-    }
-    const titleParts: string[] = [];
-    if (category === LabelCategory.UNRESOLVED_COMMENTS) {
-      const num = this.change?.unresolved_comment_count ?? 0;
-      titleParts.push(pluralize(num, 'unresolved comment'));
-    }
-    const significantLabel =
-      label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel?.name) {
-      titleParts.push(`${labelName} by ${significantLabel.name}`);
-    }
-    if (titleParts.length > 0) {
-      return titleParts.join(',\n');
-    }
-    return labelName;
-  }
-
-  // private but used in test
-  computeLabelClass(labelName: string) {
-    const classes = ['cell', 'label'];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        classes.push('u-gray-background');
-        break;
-      case LabelCategory.APPROVED:
-        classes.push('u-green');
-        break;
-      case LabelCategory.POSITIVE:
-        classes.push('u-monospace');
-        classes.push('u-green');
-        break;
-      case LabelCategory.NEGATIVE:
-        classes.push('u-monospace');
-        classes.push('u-red');
-        break;
-      case LabelCategory.REJECTED:
-        classes.push('u-red');
-        break;
-    }
-    return classes.sort().join(' ');
-  }
-
-  // private but used in test
-  computeLabelIcon(labelName: string): string {
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.APPROVED:
-        return 'gr-icons:check';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'gr-icons:comment';
-      case LabelCategory.REJECTED:
-        return 'gr-icons:close';
-      default:
-        return '';
-    }
-  }
-
-  // private but used in test
-  computeLabelCategory(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    if (!label) {
-      return LabelCategory.NOT_APPLICABLE;
-    }
-    if (label.rejected) {
-      return LabelCategory.REJECTED;
-    }
-    if (label.value && label.value < 0) {
-      return LabelCategory.NEGATIVE;
-    }
-    if (this.change?.unresolved_comment_count && labelName === 'Code-Review') {
-      return LabelCategory.UNRESOLVED_COMMENTS;
-    }
-    if (label.approved) {
-      return LabelCategory.APPROVED;
-    }
-    if (label.value && label.value > 0) {
-      return LabelCategory.POSITIVE;
-    }
-    return LabelCategory.NEUTRAL;
-  }
-
-  // private but used in test
-  computeLabelValue(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        return '';
-      case LabelCategory.APPROVED:
-        return '\u2713'; // ✓
-      case LabelCategory.POSITIVE:
-        return `+${label?.value}`;
-      case LabelCategory.NEUTRAL:
-        return '';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'u';
-      case LabelCategory.NEGATIVE:
-        return `${label?.value}`;
-      case LabelCategory.REJECTED:
-        return '\u2715'; // ✕
-      default:
-        return '';
-    }
-  }
-
   private computeRepoUrl() {
     if (!this.change) return '';
     return GerritNav.getUrlForProjectChanges(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 68d52ca..7216249 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -47,9 +47,8 @@
 } from '../../../types/common';
 import {StandardLabels} from '../../../utils/label-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {columnNames} from '../gr-change-list/gr-change-list';
 import './gr-change-list-item';
-import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+import {GrChangeListItem} from './gr-change-list-item';
 import {
   DIProviderElement,
   wrapInProvider,
@@ -60,6 +59,7 @@
 } from '../../../models/bulk-actions/bulk-actions-model';
 import {createTestAppContext} from '../../../test/test-app-context-init';
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-list-item tests', () => {
   const account = createAccountWithId();
@@ -92,226 +92,23 @@
     await element.updateComplete;
   });
 
-  test('computeLabelCategory', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.APPROVED
-    );
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.REJECTED
-    );
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.UNRESOLVED_COMMENTS
-    );
-    element.change.labels = {'Code-Review': {value: 1}};
-    element.change.unresolved_comment_count = 0;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.POSITIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.NEGATIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-  });
-
-  test('computeLabelClass', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-green');
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-red');
-    element.change.labels = {'Code-Review': {value: 1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-green u-monospace'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-monospace u-red'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-  });
-
-  test('computeLabelTitle', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelTitle('Verified'), 'Label not applicable');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Label not applicable'
-    );
-
-    element.change.labels = {Verified: {rejected: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {
-      'Code-Review': {disliked: {name: 'Diffy'}, value: -1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, value: 1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        recommended: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        approved: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, value: 1},
-    };
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment,\nCode-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 2;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '2 unresolved comments'
-    );
-  });
-
-  test('computeLabelIcon', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelIcon('missingLabel'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelIcon('Verified'), 'gr-icons:check');
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(element.computeLabelIcon('Code-Review'), 'gr-icons:comment');
-  });
-
-  test('computeLabelValue', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelValue('Verified'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '+1');
-    element.change.labels = {Verified: {value: -1}};
-    assert.equal(element.computeLabelValue('Verified'), '-1');
-    element.change.labels = {Verified: {approved: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {rejected: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✕');
-  });
-
   test('no hidden columns', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-      ' Status ',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
     await element.updateComplete;
 
-    for (const column of columnNames) {
+    for (const column of Object.values(ColumnNames)) {
       const elementClass = '.' + column.trim().toLowerCase();
       assert.isFalse(
         queryAndAssert(element, elementClass).hasAttribute('hidden')
@@ -388,20 +185,20 @@
 
   test('repo column hidden', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Reviewers',
-      'Comments',
-      'Branch',
-      'Updated',
-      'Size',
-      ' Status ',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
     await element.updateComplete;
 
-    for (const column of columnNames) {
+    for (const column of Object.values(ColumnNames)) {
       const elementClass = '.' + column.trim().toLowerCase();
       if (column === 'Repo') {
         assert.isNotOk(query(element, elementClass));
@@ -430,7 +227,7 @@
       attention_set: {},
     };
     for (let i = 0; i < reviewerIds.length; i++) {
-      element.change!.reviewers.REVIEWER!.push({
+      element.change.reviewers.REVIEWER!.push({
         _account_id: reviewerIds[i] as AccountId,
         name: reviewerNames[i],
       });
@@ -588,7 +385,11 @@
         </div>
       </a>
       <span class="placeholder"> -- </span>
-      <gr-account-link highlightattention=""></gr-account-link>
+      <gr-account-label
+        deselected=""
+        clickable=""
+        highlightattention=""
+      ></gr-account-label>
       <div></div>
       <span></span>
       <a class="fullRepo" href=""> test-project </a>
@@ -611,10 +412,7 @@
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
       name: StandardLabels.CODE_REVIEW,
-      submittability_expression_result: {
-        ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
-      },
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
     const change: ChangeInfo = {
       ...createChange(),
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
new file mode 100644
index 0000000..34080a1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -0,0 +1,419 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {ProgressStatus, ReviewerState} from '../../../constants/constants';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  NumericChangeId,
+  ServerInfo,
+} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-button/gr-button';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {getAppContext} from '../../../services/app-context';
+import {
+  GrReviewerSuggestionsProvider,
+  ReviewerSuggestionsProvider,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import '../../shared/gr-account-list/gr-account-list';
+import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {allSettled} from '../../../utils/async-util';
+import {listForSentence} from '../../../utils/string-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {
+  AccountInput,
+  AccountInputDetail,
+} from '../../shared/gr-account-list/gr-account-list';
+import '@polymer/iron-icon/iron-icon';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {intersection} from '../../../utils/common-util';
+import {accountOrGroupKey} from '../../../utils/account-util';
+
+@customElement('gr-change-list-reviewer-flow')
+export class GrChangeListReviewerFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  // contents are given to gr-account-lists to mutate
+  @state() private updatedAccountsByReviewerState: Map<
+    ReviewerState,
+    AccountInput[]
+  > = new Map([
+    [ReviewerState.REVIEWER, []],
+    [ReviewerState.CC, []],
+  ]);
+
+  @state() private suggestionsProviderByReviewerState: Map<
+    ReviewerState,
+    ReviewerSuggestionsProvider
+  > = new Map();
+
+  @state() private progressByChangeNum = new Map<
+    NumericChangeId,
+    ProgressStatus
+  >();
+
+  @state() private isOverlayOpen = false;
+
+  @state() private serverConfig?: ServerInfo;
+
+  @query('gr-overlay') private overlay!: GrOverlay;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private getConfigModel = resolve(this, configModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private isLoggedIn = false;
+
+  private account?: AccountDetailInfo;
+
+  static override get styles() {
+    return css`
+      gr-dialog {
+        width: 60em;
+      }
+      .grid {
+        display: grid;
+        grid-template-columns: min-content 1fr;
+        column-gap: var(--spacing-l);
+      }
+      gr-account-list {
+        display: flex;
+        flex-wrap: wrap;
+      }
+      .warning {
+        display: flex;
+        align-items: center;
+        gap: var(--spacing-xl);
+        padding: var(--spacing-l);
+        padding-left: var(--spacing-xl);
+        background-color: var(--yellow-50);
+      }
+      .grid + .warning {
+        margin-top: var(--spacing-l);
+      }
+      .warning + .warning {
+        margin-top: var(--spacing-s);
+      }
+      iron-icon {
+        color: var(--orange-800);
+        --iron-icon-height: 18px;
+        --iron-icon-width: 18px;
+      }
+    `;
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => (this.selectedChanges = selectedChanges)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+    subscribe(
+      this,
+      () => getAppContext().userModel.account$,
+      account => (this.account = account)
+    );
+  }
+
+  override render() {
+    // TODO: factor out button+dialog component with promise-progress tracking
+    return html`
+      <gr-button
+        id="start-flow"
+        .disabled=${this.isFlowDisabled()}
+        flatten
+        @click=${() => this.openOverlay()}
+        >add reviewer/cc</gr-button
+      >
+      <gr-overlay with-backdrop>
+        ${this.isOverlayOpen ? this.renderDialog() : nothing}
+      </gr-overlay>
+    `;
+  }
+
+  private renderDialog() {
+    const overallStatus = getOverallStatus(this.progressByChangeNum);
+    return html`
+      <gr-dialog
+        @cancel=${() => this.closeOverlay()}
+        @confirm=${() => this.onConfirm(overallStatus)}
+        .confirmLabel=${this.getConfirmLabel(overallStatus)}
+        .disabled=${overallStatus === ProgressStatus.RUNNING}
+      >
+        <div slot="header">Add reviewer / CC</div>
+        <div slot="main">
+          <div class="grid">
+            <span>Reviewers</span>
+            ${this.renderAccountList(
+              ReviewerState.REVIEWER,
+              'reviewer-list',
+              'Add reviewer'
+            )}
+            <span>CC</span>
+            ${this.renderAccountList(ReviewerState.CC, 'cc-list', 'Add CC')}
+          </div>
+          ${this.renderAnyOverwriteWarnings()}
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderAccountList(
+    reviewerState: ReviewerState,
+    id: string,
+    placeholder: string
+  ) {
+    const updatedAccounts =
+      this.updatedAccountsByReviewerState.get(reviewerState);
+    const suggestionsProvider =
+      this.suggestionsProviderByReviewerState.get(reviewerState);
+    if (!updatedAccounts || !suggestionsProvider) {
+      return;
+    }
+    // @accounts-changed will notify us when an account is added or removed, so
+    // we need to re-render to update warning messages.
+    return html`
+      <gr-account-list
+        id=${id}
+        .accounts=${updatedAccounts}
+        .removableValues=${[]}
+        .suggestionsProvider=${suggestionsProvider}
+        .placeholder=${placeholder}
+        @accounts-changed=${() => this.requestUpdate()}
+        @account-added=${(e: CustomEvent<AccountInputDetail>) =>
+          this.onAccountAdded(reviewerState, e)}
+      >
+      </gr-account-list>
+    `;
+  }
+
+  private renderAnyOverwriteWarnings() {
+    return html`
+      ${this.renderAnyOverwriteWarning(ReviewerState.REVIEWER)}
+      ${this.renderAnyOverwriteWarning(ReviewerState.CC)}
+    `;
+  }
+
+  private renderAnyOverwriteWarning(currentReviewerState: ReviewerState) {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const overwrittenNames =
+      this.getOverwrittenDisplayNames(currentReviewerState);
+    if (overwrittenNames.length === 0) {
+      return nothing;
+    }
+    const pluralizedVerb = overwrittenNames.length === 1 ? 'is a' : 'are';
+    const currentLabel = `${
+      currentReviewerState === ReviewerState.CC ? 'CC' : 'reviewer'
+    }${overwrittenNames.length > 1 ? 's' : ''}`;
+    const updatedLabel =
+      updatedReviewerState === ReviewerState.CC ? 'CC' : 'reviewer';
+    return html`
+      <div class="warning">
+        <iron-icon icon="gr-icons:warning"></iron-icon>
+        ${listForSentence(overwrittenNames)} ${pluralizedVerb} ${currentLabel}
+        on some selected changes and will be moved to ${updatedLabel} on all
+        changes.
+      </div>
+    `;
+  }
+
+  private getOverwrittenDisplayNames(
+    currentReviewerState: ReviewerState
+  ): string[] {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const accountsInCurrentState = this.selectedChanges
+      .flatMap(change => change.reviewers[currentReviewerState] ?? [])
+      .filter(account => account?._account_id !== undefined);
+    return this.updatedAccountsByReviewerState
+      .get(updatedReviewerState)!
+      .filter(account =>
+        accountsInCurrentState.some(
+          otherAccount =>
+            accountOrGroupKey(otherAccount) === accountOrGroupKey(account)
+        )
+      )
+      .map(reviewer => getDisplayName(this.serverConfig, reviewer));
+  }
+
+  private openOverlay() {
+    this.resetFlow();
+    this.isOverlayOpen = true;
+    this.overlay.open();
+  }
+
+  private closeOverlay() {
+    this.isOverlayOpen = false;
+    this.overlay.close();
+  }
+
+  private resetFlow() {
+    this.progressByChangeNum = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.NOT_STARTED,
+      ])
+    );
+    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC] as const) {
+      this.updatedAccountsByReviewerState.set(
+        state,
+        this.getCurrentAccounts(state)
+      );
+      if (this.selectedChanges.length > 0) {
+        this.suggestionsProviderByReviewerState.set(
+          state,
+          this.createSuggestionsProvider(state)
+        );
+      }
+    }
+    this.requestUpdate();
+  }
+
+  /* Removes accounts from one list when they are added to the other */
+  private onAccountAdded(
+    reviewerState: ReviewerState,
+    event: CustomEvent<AccountInputDetail>
+  ) {
+    const oppositeReviewerState =
+      reviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
+      oppositeReviewerState
+    )!;
+    const oppositeUpdatedAccountIndex = oppositeUpdatedAccounts.findIndex(
+      acc => accountOrGroupKey(acc) === accountOrGroupKey(event.detail.account)
+    );
+    if (oppositeUpdatedAccountIndex >= 0) {
+      oppositeUpdatedAccounts.splice(oppositeUpdatedAccountIndex, 1);
+      this.requestUpdate();
+    }
+  }
+
+  private onConfirm(overallStatus: ProgressStatus) {
+    switch (overallStatus) {
+      case ProgressStatus.NOT_STARTED:
+        this.saveReviewers();
+        break;
+      case ProgressStatus.SUCCESSFUL:
+        this.overlay.close();
+        break;
+      case ProgressStatus.FAILED:
+        this.overlay.close();
+        break;
+    }
+  }
+
+  private async saveReviewers() {
+    this.reportingService.reportInteraction('bulk-action', {
+      type: 'add-reviewer',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.progressByChangeNum = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.RUNNING,
+      ])
+    );
+    const inFlightActions = this.getBulkActionsModel().addReviewers(
+      this.updatedAccountsByReviewerState,
+      getReplyByReason(this.account, this.serverConfig)
+    );
+
+    await allSettled(
+      inFlightActions.map((promise, index) => {
+        const change = this.selectedChanges[index];
+        return promise
+          .then(() => {
+            this.progressByChangeNum.set(
+              change._number,
+              ProgressStatus.SUCCESSFUL
+            );
+            this.requestUpdate();
+          })
+          .catch(() => {
+            this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
+            this.requestUpdate();
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChangeNum) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'add-reviewer',
+        count: Array.from(this.progressByChangeNum.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    }
+  }
+
+  private isFlowDisabled() {
+    // No additional checks are necessary. If the user has visibility enough to
+    // see the change, they have permission enough to add reviewers/cc.
+    return this.selectedChanges.length === 0;
+  }
+
+  private getConfirmLabel(overallStatus: ProgressStatus) {
+    return overallStatus === ProgressStatus.NOT_STARTED
+      ? 'Add'
+      : overallStatus === ProgressStatus.RUNNING
+      ? 'Running'
+      : 'Close';
+  }
+
+  private getCurrentAccounts(reviewerState: ReviewerState) {
+    const reviewersPerChange = this.selectedChanges.map(
+      change => change.reviewers[reviewerState] ?? []
+    );
+    return intersection(reviewersPerChange);
+  }
+
+  private createSuggestionsProvider(
+    state: ReviewerState.CC | ReviewerState.REVIEWER
+  ): ReviewerSuggestionsProvider {
+    const suggestionsProvider = new GrReviewerSuggestionsProvider(
+      this.restApiService,
+      state,
+      this.serverConfig,
+      this.isLoggedIn,
+      ...this.selectedChanges.map(change => change._number)
+    );
+    return suggestionsProvider;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-reviewer-flow': GrChangeListReviewerFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
new file mode 100644
index 0000000..4a142e4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -0,0 +1,433 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {SinonStubbedMember} from 'sinon';
+import {AccountInfo, GroupInfo, ReviewerState} from '../../../api/rest-api';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup-karma';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+  createGroupInfo,
+} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {ValueChangedEvent} from '../../../types/events';
+import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import './gr-change-list-reviewer-flow';
+import type {GrChangeListReviewerFlow} from './gr-change-list-reviewer-flow';
+
+const accounts: AccountInfo[] = [
+  createAccountWithIdNameAndEmail(0),
+  createAccountWithIdNameAndEmail(1),
+  createAccountWithIdNameAndEmail(2),
+  createAccountWithIdNameAndEmail(3),
+  createAccountWithIdNameAndEmail(4),
+  createAccountWithIdNameAndEmail(5),
+];
+const groups: GroupInfo[] = [createGroupInfo('groupId')];
+const changes: ChangeInfo[] = [
+  {
+    ...createChange(),
+    _number: 1 as NumericChangeId,
+    subject: 'Subject 1',
+    reviewers: {
+      REVIEWER: [accounts[0], accounts[1]],
+      CC: [accounts[3], accounts[4]],
+    },
+  },
+  {
+    ...createChange(),
+    _number: 2 as NumericChangeId,
+    subject: 'Subject 2',
+    reviewers: {REVIEWER: [accounts[0]], CC: [accounts[3]]},
+  },
+];
+
+suite('gr-change-list-reviewer-flow tests', () => {
+  let element: GrChangeListReviewerFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    stubRestApi('getDetailedChangesWithActions').resolves(changes);
+    reportingStub = stubReporting('reportInteraction');
+    model = new BulkActionsModel(getAppContext().restApiService);
+    model.sync(changes);
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-reviewer-flow')!;
+    await selectChange(changes[0]);
+    await selectChange(changes[1]);
+    await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+    await element.updateComplete;
+  });
+
+  test('skips dialog render when closed', async () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-button
+        id="start-flow"
+        flatten=""
+        aria-disabled="false"
+        role="button"
+        tabindex="0"
+        >add reviewer/cc</gr-button
+      >
+      <gr-overlay
+        aria-hidden="true"
+        with-backdrop=""
+        tabindex="-1"
+        style="outline: none; display: none;"
+      ></gr-overlay>
+    `);
+  });
+
+  test('flow button enabled when changes selected', async () => {
+    const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+    assert.isFalse(button.disabled);
+  });
+
+  test('flow button disabled when no changes selected', async () => {
+    model.clearSelectedChangeNums();
+    await waitUntilObserved(model.selectedChanges$, s => s.length === 0);
+    await element.updateComplete;
+
+    const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+    assert.isTrue(button.disabled);
+  });
+
+  test('overlay hidden before flow button clicked', async () => {
+    const overlay = queryAndAssert<GrOverlay>(element, 'gr-overlay');
+    assert.isFalse(overlay.opened);
+  });
+
+  test('flow button click shows overlay', async () => {
+    const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+    button.click();
+    await element.updateComplete;
+
+    const overlay = queryAndAssert<GrOverlay>(element, 'gr-overlay');
+    assert.isTrue(overlay.opened);
+  });
+
+  suite('dialog flow', () => {
+    let saveChangesPromises: MockPromise<Response>[];
+    let saveChangeReviewStub: sinon.SinonStub;
+    let dialog: GrDialog;
+
+    async function resolvePromises() {
+      saveChangesPromises[0].resolve(new Response());
+      saveChangesPromises[1].resolve(new Response());
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      saveChangesPromises = [];
+      saveChangeReviewStub = stubRestApi('saveChangeReview');
+      for (let i = 0; i < changes.length; i++) {
+        const promise = mockPromise<Response>();
+        saveChangesPromises.push(promise);
+        saveChangeReviewStub
+          .withArgs(changes[i]._number, sinon.match.any, sinon.match.any)
+          .returns(promise);
+      }
+
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      dialog = queryAndAssert<GrDialog>(element, 'gr-dialog');
+      await dialog.updateComplete;
+    });
+
+    test('renders dialog when opened', async () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >add reviewer/cc</gr-button
+        >
+        <gr-overlay
+          with-backdrop=""
+          tabindex="-1"
+          style="outline: none; display: none;"
+        >
+          <gr-dialog role="dialog">
+            <div slot="header">Add reviewer / CC</div>
+            <div slot="main">
+              <div class="grid">
+                <span>Reviewers</span>
+                <gr-account-list id="reviewer-list"></gr-account-list>
+                <span>CC</span>
+                <gr-account-list id="cc-list"></gr-account-list>
+              </div>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `);
+    });
+
+    test('only lists reviewers/CCs shared by all changes', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      // does not include account 1
+      assert.sameMembers(reviewerList.accounts, [accounts[0]]);
+      // does not include account 4
+      assert.sameMembers(ccList.accounts, [accounts[3]]);
+    });
+
+    test('adds reviewer & CC', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      ccList.accounts.push(accounts[5]);
+      await flush();
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-reviewer',
+        selectedChangeCount: 2,
+      });
+
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
+          ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
+        changes[1]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
+          ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+    });
+
+    test('removes from reviewer list when added to cc', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(reviewerList.accounts, [accounts[0]]);
+
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[0],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await flush();
+
+      assert.isEmpty(reviewerList.accounts);
+    });
+
+    test('removes from cc list when added to reviewer', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(ccList.accounts, [accounts[3]]);
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[3],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await flush();
+
+      assert.isEmpty(ccList.accounts);
+    });
+
+    test('confirm button text updates', async () => {
+      assert.equal(dialog.confirmLabel, 'Add');
+
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+
+      assert.equal(dialog.confirmLabel, 'Running');
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.equal(dialog.confirmLabel, 'Close');
+    });
+
+    test('renders warnings when reviewer/cc are overwritten', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[4],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[1],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await flush();
+
+      // prettier and shadowDom string don't agree on long text in divs
+      expect(element).shadowDom.to.equal(
+        /* prettier-ignore */
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >add reviewer/cc</gr-button
+          >
+          <gr-overlay with-backdrop="" tabindex="-1">
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span>Reviewers</span>
+                  <gr-account-list id="reviewer-list"></gr-account-list>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                </div>
+                <div class="warning">
+                  <iron-icon icon="gr-icons:warning"></iron-icon>
+                  User-1 is a reviewer
+        on some selected changes and will be moved to CC on all
+        changes.
+                </div>
+                <div class="warning">
+                  <iron-icon icon="gr-icons:warning"></iron-icon>
+                  User-4 is a CC
+        on some selected changes and will be moved to reviewer on all
+        changes.
+                </div>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `,
+        {
+          // gr-overlay sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+        }
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index ddc45fc..330a93b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -5,8 +5,9 @@
  */
 
 import {LitElement, html, css, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import '../gr-change-list-action-bar/gr-change-list-action-bar';
 import {
   CLOSED,
   YOUR_TURN,
@@ -26,6 +27,7 @@
   bulkActionsModelToken,
   BulkActionsModel,
 } from '../../../models/bulk-actions/bulk-actions-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
@@ -85,6 +87,8 @@
   @property({type: Object})
   account: AccountInfo | undefined = undefined;
 
+  @state() showBulkActionsHeader = false;
+
   private readonly flagsService = getAppContext().flagsService;
 
   bulkActionsModel: BulkActionsModel = new BulkActionsModel(
@@ -107,6 +111,20 @@
           font-weight: var(--font-weight-normal);
           line-height: var(--line-height-small);
         }
+        /*
+         * checkbox styles match checkboxes in <gr-change-list-item> rows to
+         * vertically align with them.
+         */
+        input.selection-checkbox {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-sizing: border-box;
+          color: var(--primary-text-color);
+          margin: 0px;
+          padding: var(--spacing-s);
+          vertical-align: middle;
+        }
       `,
     ];
   }
@@ -114,6 +132,12 @@
   constructor() {
     super();
     provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
+    subscribe(
+      this,
+      () => this.bulkActionsModel.selectedChangeNums$,
+      selectedChanges =>
+        (this.showBulkActionsHeader = selectedChanges.length > 0)
+    );
   }
 
   override willUpdate(changedProperties: PropertyValues) {
@@ -121,7 +145,9 @@
       // In case the list of changes is updated due to auto reloading, we want
       // to ensure the model removes any stale change that is not a part of the
       // new section changes.
-      this.bulkActionsModel.sync(this.changeSection.results);
+      if (this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) {
+        this.bulkActionsModel.sync(this.changeSection.results);
+      }
     }
   }
 
@@ -150,11 +176,9 @@
           ?aria-hidden=${!this.showStar}
           ?hidden=${!this.showStar}
         ></td>
-        <td class="cell" colspan="${colSpan}">
-          ${this.changeSection!.emptyStateSlotName
-            ? html`<slot
-                name="${this.changeSection!.emptyStateSlotName}"
-              ></slot>`
+        <td class="cell" colspan=${colSpan}>
+          ${this.changeSection.emptyStateSlotName
+            ? html`<slot name=${this.changeSection.emptyStateSlotName}></slot>`
             : 'No changes'}
         </td>
       </tr>
@@ -173,15 +197,14 @@
       <tbody>
         <tr class="groupHeader">
           <td aria-hidden="true" class="leftPadding"></td>
-          ${this.renderSelectionHeader()}
           <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
-          <td class="cell" colspan="${colSpan}">
+          <td class="cell" colspan=${colSpan}>
             <h2 class="heading-3">
               <a
-                href="${this.sectionHref(this.changeSection!.query)}"
+                href=${this.sectionHref(this.changeSection.query)}
                 class="section-title"
               >
-                <span class="section-name">${this.changeSection!.name}</span>
+                <span class="section-name">${this.changeSection.name}</span>
                 <span class="section-count-label"
                   >${this.changeSection.countLabel}</span
                 >
@@ -196,35 +219,51 @@
   private renderColumnHeaders(columns: string[]) {
     return html`
       <tr class="groupTitle">
-        <td class="leftPadding" aria-hidden="true"></td>
-        ${this.renderSelectionHeader()}
-        <td
-          class="star"
-          aria-label="Star status column"
-          ?hidden=${!this.showStar}
-        ></td>
-        <td class="number" ?hidden=${!this.showNumber}>#</td>
-        ${columns.map(item => this.renderHeaderCell(item))}
-        ${this.labelNames?.map(labelName => this.renderLabelHeader(labelName))}
-        ${this.dynamicHeaderEndpoints?.map(pluginHeader =>
-          this.renderEndpointHeader(pluginHeader)
-        )}
+        ${this.showBulkActionsHeader &&
+        this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)
+          ? html`<gr-change-list-action-bar></gr-change-list-action-bar>`
+          : html` <td class="leftPadding" aria-hidden="true"></td>
+              ${this.renderSelectionHeader()}
+              <td
+                class="star"
+                aria-label="Star status column"
+                ?hidden=${!this.showStar}
+              ></td>
+              <td class="number" ?hidden=${!this.showNumber}>#</td>
+              ${columns.map(item => this.renderHeaderCell(item))}
+              ${this.labelNames?.map(labelName =>
+                this.renderLabelHeader(labelName)
+              )}
+              ${this.dynamicHeaderEndpoints?.map(pluginHeader =>
+                this.renderEndpointHeader(pluginHeader)
+              )}`}
       </tr>
     `;
   }
 
   private renderSelectionHeader() {
     if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
-    return html`<td aria-hidden="true" class="selection"></td>`;
+    // TODO: Currently the action bar replaces this checkbox and has it's own
+    // deselect checkbox. Instead, this checkbox should do both select/deselect
+    // and always be visible.
+    return html`
+      <td aria-hidden="true" class="selection">
+        <input
+          class="selection-checkbox"
+          type="checkbox"
+          @click=${() => this.bulkActionsModel.selectAll()}
+        />
+      </td>
+    `;
   }
 
   private renderHeaderCell(item: string) {
-    return html`<td class="${item.toLowerCase()}">${item}</td>`;
+    return html`<td class=${item.toLowerCase()}>${item}</td>`;
   }
 
   private renderLabelHeader(labelName: string) {
     return html`
-      <td class="label" title="${labelName}">
+      <td class="label" title=${labelName}>
         ${computeLabelShortcut(labelName)}
       </td>
     `;
@@ -233,7 +272,7 @@
   private renderEndpointHeader(pluginHeader: string) {
     return html`
       <td class="endpoint">
-        <gr-endpoint-decorator .name="${pluginHeader}"></gr-endpoint-decorator>
+        <gr-endpoint-decorator .name=${pluginHeader}></gr-endpoint-decorator>
       </td>
     `;
   }
@@ -252,13 +291,13 @@
         ?selected=${selected}
         .change=${change}
         .config=${this.config}
-        .sectionName=${this.changeSection!.name}
+        .sectionName=${this.changeSection.name}
         .visibleChangeTableColumns=${columns}
         .showNumber=${this.showNumber}
         ?showStar=${this.showStar}
         tabindex=${ifDefined(tabindex)}
         .labelNames=${this.labelNames}
-        aria-label="${ariaLabel}"
+        aria-label=${ariaLabel}
       ></gr-change-list-item>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index 6f21e63..b1a33a9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -21,10 +21,12 @@
   query,
   queryAndAssert,
   stubFlags,
+  waitUntilObserved,
 } from '../../../test/test-utils';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
-import {columnNames, ChangeListSection} from '../gr-change-list/gr-change-list';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
 import {fixture, html} from '@open-wc/testing-helpers';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-list section', () => {
   let element: GrChangeListSection;
@@ -51,7 +53,7 @@
       html`<gr-change-list-section
         .account=${createAccountDetailWithId(1)}
         .config=${createServerInfo()}
-        .visibleChangeTableColumns=${columnNames}
+        .visibleChangeTableColumns=${Object.values(ColumnNames)}
         .changeSection=${changeSection}
       ></gr-change-list-section> `
     );
@@ -67,9 +69,28 @@
     assert.isOk(query(element, '.selection'));
   });
 
+  test('selection header is only shown if experiment is enabled', async () => {
+    element.bulkActionsModel.setState({
+      ...element.bulkActionsModel.getState(),
+      selectedChangeNums: [1 as NumericChangeId],
+    });
+    await waitUntilObserved(
+      element.bulkActionsModel.selectedChangeNums$,
+      s => s.length === 1
+    );
+
+    assert.isNotOk(query(element, 'gr-change-list-action-bar'));
+    stubFlags('isEnabled').returns(true);
+    element.requestUpdate();
+    await element.updateComplete;
+    queryAndAssert(element, 'gr-change-list-action-bar');
+  });
+
   suite('bulk actions selection', () => {
+    let isEnabled: sinon.SinonStub;
     setup(async () => {
-      stubFlags('isEnabled').returns(true);
+      isEnabled = stubFlags('isEnabled');
+      isEnabled.returns(true);
       element.requestUpdate();
       await element.updateComplete;
     });
@@ -93,6 +114,91 @@
 
       assert.isTrue(syncStub.called);
     });
+
+    test('changing section does on trigger model sync when flag is disabled', async () => {
+      isEnabled.returns(false);
+      const syncStub = sinon.stub(element.bulkActionsModel, 'sync');
+      assert.isFalse(syncStub.called);
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+
+      assert.isFalse(syncStub.called);
+    });
+
+    test('actions header is enabled/disabled based on selected changes', async () => {
+      element.bulkActionsModel.setState({
+        ...element.bulkActionsModel.getState(),
+        selectedChangeNums: [],
+      });
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 0
+      );
+      assert.isFalse(element.showBulkActionsHeader);
+
+      element.bulkActionsModel.setState({
+        ...element.bulkActionsModel.getState(),
+        selectedChangeNums: [1 as NumericChangeId],
+      });
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 1
+      );
+      assert.isTrue(element.showBulkActionsHeader);
+    });
+
+    test('select all checkbox checks all when none are selected', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+      let rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
+
+      const checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 2
+      );
+      await element.updateComplete;
+
+      rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[0], 'input').checked);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[1], 'input').checked);
+    });
   });
 
   test('colspans', async () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
new file mode 100644
index 0000000..ffdc9f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -0,0 +1,371 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, TopicName} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+
+@customElement('gr-change-list-topic-flow')
+export class GrChangeListTopicFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private topicToAdd: TopicName = '' as TopicName;
+
+  @state() private existingTopicSuggestions: TopicName[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingTopics: Set<TopicName> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--gray-300);
+        }
+        .chip.selected {
+          color: var(--blue-800);
+          background-color: var(--blue-50);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Topic</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${when(
+                this.selectedChanges.some(change => change.topic),
+                () => this.renderExistingTopicsMode(),
+                () => this.renderNoExistingTopicsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingTopicsMode() {
+    const topics = this.selectedChanges
+      .map(change => change.topic)
+      .filter(notUndefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingTopics.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <div class="chips">
+        ${topics.map(name => this.renderExistingTopicChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="apply-to-all-button"
+            flatten
+            ?disabled=${this.selectedExistingTopics.size !== 1}
+            @click=${this.applyTopicToAll}
+            >Apply to all</gr-button
+          >
+          <gr-button
+            id="remove-topics-button"
+            flatten
+            ?disabled=${removeDisabled}
+            @click=${this.removeTopics}
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingTopicChip(name: TopicName) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingTopics.has(name),
+    };
+    return html`
+      <span
+        role="button"
+        aria-label=${name as string}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingTopicSelected(name)}
+      >
+        ${name}
+      </span>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    if (this.overallProgress === ProgressStatus.RUNNING) {
+      return html`
+        <span class="loadingSpin"></span>
+        <span class="loadingText">${this.loadingText}</span>
+      `;
+    } else if (this.errorText !== undefined) {
+      return html`<div class="error">${this.errorText}</div>`;
+    }
+    return nothing;
+  }
+
+  private renderNoExistingTopicsMode() {
+    const isCreateNewTopicDisabled =
+      this.topicToAdd === '' ||
+      this.existingTopicSuggestions.includes(this.topicToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const isApplyTopicDisabled =
+      this.topicToAdd === '' ||
+      !this.existingTopicSuggestions.includes(this.topicToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <!--
+        The .query function needs to be bound to this because lit's autobind
+        seems to work only for @event handlers.
+        'this.getTopicSuggestions.bind(this)' gets in trouble with our linter
+        even though the bind is necessary here, so an anonymous function is used
+        instead.
+      -->
+      <gr-autocomplete
+        .text=${this.topicToAdd}
+        .query=${(query: string) => this.getTopicSuggestions(query)}
+        show-blue-focus-border
+        placeholder="Type topic name to create or filter topics"
+        @text-changed=${(e: ValueChangedEvent<TopicName>) =>
+          (this.topicToAdd = e.detail.value)}
+      ></gr-autocomplete>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="create-new-topic-button"
+            flatten
+            @click=${() => this.addTopic('Creating topic...')}
+            .disabled=${isCreateNewTopicDisabled}
+            >Create new topic</gr-button
+          >
+          <gr-button
+            id="apply-topic-button"
+            flatten
+            @click=${() => this.addTopic('Applying topic...')}
+            .disabled=${isApplyTopicDisabled}
+            >Apply</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    if (this.isDropdownOpen) {
+      this.closeDropdown();
+    } else {
+      this.reset();
+      this.openDropdown();
+    }
+  }
+
+  private reset() {
+    this.topicToAdd = '' as TopicName;
+    this.selectedExistingTopics = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getTopicSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarTopic(
+      query
+    );
+    this.existingTopicSuggestions = (suggestions ?? [])
+      .map(change => change.topic)
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingTopicSuggestions.map(topic => {
+      return {name: topic, value: topic};
+    });
+  }
+
+  private removeTopics() {
+    this.loadingText = `Removing topic${
+      this.selectedExistingTopics.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.topic && this.selectedExistingTopics.has(change.topic)
+        )
+        .map(change => this.restApiService.setChangeTopic(change._number, ''))
+    );
+  }
+
+  private applyTopicToAll() {
+    this.loadingText = 'Applying to all';
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(
+          change._number,
+          Array.from(this.selectedExistingTopics.values())[0]
+        )
+      )
+    );
+  }
+
+  private addTopic(loadingText: string) {
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, this.topicToAdd)
+      )
+    );
+  }
+
+  private async trackPromises(promises: Promise<string>[]) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.dropdown?.close();
+      this.isDropdownOpen = false;
+      // TODO: fire reload of dashboard
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      // TODO: when some are rejected, show error and Cancel button
+    }
+  }
+
+  private toggleExistingTopicSelected(name: TopicName) {
+    if (this.selectedExistingTopics.has(name)) {
+      this.selectedExistingTopics.delete(name);
+    } else {
+      this.selectedExistingTopics.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-topic-flow': GrChangeListTopicFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
new file mode 100644
index 0000000..fd78479
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -0,0 +1,540 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-topic-flow';
+import type {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
+
+suite('gr-change-list-topic-flow tests', () => {
+  let element: GrChangeListTopicFlow;
+  let model: BulkActionsModel;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >Topic</gr-button
+        >
+        <iron-dropdown
+          aria-disabled="false"
+          aria-hidden="true"
+          style="outline: none; display: none;"
+          vertical-align="auto"
+          horizontal-align="auto"
+        >
+        </iron-dropdown>
+      `);
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('changes in existing topics', () => {
+    const changesWithTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        topic: 'topic1' as TopicName,
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        topic: 'topic2' as TopicName,
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changesWithTopics);
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithTopics[0]);
+      await selectChange(changesWithTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders existing-topics flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <span role="button" aria-label="topic1" class="chip"
+                  >topic1</span
+                >
+                <span role="button" aria-label="topic2" class="chip"
+                  >topic2</span
+                >
+              </div>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="apply-to-all-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply to all</gr-button
+                  >
+                  <gr-button
+                    id="remove-topics-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Remove</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('remove single topic', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledOnce);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+    });
+
+    test('remove multiple topics', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topics...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        '',
+      ]);
+    });
+
+    test('can only apply a single topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies topic to all changes', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        'topic1',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        'topic1',
+      ]);
+    });
+  });
+
+  suite('change have no existing topics', () => {
+    const changesWithNoTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoTopics
+      );
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithNoTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithNoTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithNoTopics[0]);
+      await selectChange(changesWithNoTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders no-existing-topics flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <gr-autocomplete
+                placeholder="Type topic name to create or filter topics"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="create-new-topic-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Create new topic</gr-button
+                  >
+                  <gr-button
+                    id="apply-topic-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('create new topic', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-topic-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#create-new-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+    });
+
+    test('apply topic', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#create-new-topic-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#apply-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying topic...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 142abaa..a3c755f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -110,24 +110,35 @@
     this.addEventListener('next-page', () => this.handleNextPage());
     this.addEventListener('previous-page', () => this.handlePreviousPage());
     this.addEventListener('reload', () => this.reload());
-    // We are not currently verifying if the view is actually visible. We rely
-    // on gr-app-element to restamp the component if view changes
-    document.addEventListener('visibilitychange', () => {
-      if (document.visibilityState === 'visible') {
-        if (
-          Date.now() - this.lastVisibleTimestampMs >
-          RELOAD_DASHBOARD_INTERVAL_MS
-        )
-          this.reload();
-      } else {
-        this.lastVisibleTimestampMs = Date.now();
-      }
-    });
   }
 
+  private readonly visibilityChangeListener = () => {
+    if (document.visibilityState === 'visible') {
+      if (
+        Date.now() - this.lastVisibleTimestampMs >
+        RELOAD_DASHBOARD_INTERVAL_MS
+      )
+        this.reload();
+    } else {
+      this.lastVisibleTimestampMs = Date.now();
+    }
+  };
+
   override connectedCallback() {
     super.connectedCallback();
     this.loadPreferences();
+    document.addEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+  }
+
+  override disconnectedCallback() {
+    document.removeEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    super.disconnectedCallback();
   }
 
   static override get styles() {
@@ -178,10 +189,13 @@
   }
 
   override render() {
-    if (this.loading) return html`<div class="loading">Loading...</div>`;
     const loggedIn = !!(this.account && Object.keys(this.account).length > 0);
+    // In case of an internal reload we want the ChangeList section components
+    // to remain in the DOM so that the Bulk Actions Model associated with them
+    // is not recreated after the reload resulting in user selections being lost
     return html`
-      <div>
+      <div class="loading" ?hidden=${!this.loading}>Loading...</div>
+      <div ?hidden=${this.loading}>
         ${this.renderRepoHeader()} ${this.renderUserHeader(loggedIn)}
         <gr-change-list
           .account=${this.account}
@@ -234,7 +248,7 @@
     if (this.offset === 0) return;
 
     return html`
-      <a id="prevArrow" href="${this.computeNavLink(-1)}">
+      <a id="prevArrow" href=${this.computeNavLink(-1)}>
         <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
       </a>
     `;
@@ -250,7 +264,7 @@
       return;
 
     return html`
-      <a id="nextArrow" href="${this.computeNavLink(1)}">
+      <a id="nextArrow" href=${this.computeNavLink(1)}>
         <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
         </iron-icon>
       </a>
@@ -291,7 +305,9 @@
       this.viewState.selectedChangeIndex = 0;
       this.viewState.query = this.query;
       this.viewState.offset = this.offset;
-      fire(this, 'view-state-changed', {value: this.viewState});
+      fire(this, 'view-state-change-list-view-changed', {
+        value: this.viewState,
+      });
     }
 
     // NOTE: This method may be called before attachment. Fire title-change
@@ -416,13 +432,13 @@
   private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
     if (!this.viewState) return;
     this.viewState.selectedChangeIndex = e.detail.value;
-    fire(this, 'view-state-changed', {value: this.viewState});
+    fire(this, 'view-state-change-list-view-changed', {value: this.viewState});
   }
 }
 
 declare global {
   interface HTMLElementEventMap {
-    'view-state-changed': ValueChangedEvent<ChangeListViewState>;
+    'view-state-change-list-view-changed': ValueChangedEvent<ChangeListViewState>;
   }
   interface HTMLElementTagNameMap {
     'gr-change-list-view': GrChangeListView;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index 0639620..0633f46 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -20,8 +20,13 @@
 import {GrChangeListView} from './gr-change-list-view';
 import {page} from '../../../utils/page-wrapper-utils';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import 'lodash/lodash';
-import {mockPromise, query, stubRestApi} from '../../../test/test-utils';
+import {
+  mockPromise,
+  query,
+  stubRestApi,
+  queryAndAssert,
+  stubFlags,
+} from '../../../test/test-utils';
 import {createChange} from '../../../test/test-data-generators.js';
 import {
   ChangeInfo,
@@ -29,6 +34,8 @@
   NumericChangeId,
   RepoName,
 } from '../../../api/rest-api.js';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {waitUntil} from '@open-wc/testing-helpers';
 
 const basicFixture = fixtureFromElement('gr-change-list-view');
 
@@ -51,6 +58,46 @@
     await element.updateComplete;
   });
 
+  suite('bulk actions', () => {
+    let getChangesStub: sinon.SinonStub;
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      getChangesStub = sinon.stub(element, 'getChanges');
+      getChangesStub.returns(Promise.resolve([createChange()]));
+      element.loading = false;
+      element.reload();
+      await waitUntil(() => element.loading === false);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('checkboxes remain checked after soft reload', async () => {
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        query(
+          query(query(element, 'gr-change-list'), 'gr-change-list-section'),
+          'gr-change-list-item'
+        ),
+        '.selection > input'
+      );
+      tap(checkbox);
+      await waitUntil(() => checkbox.checked);
+
+      getChangesStub.restore();
+      getChangesStub.returns(Promise.resolve([[createChange()]]));
+
+      element.reload();
+      await element.updateComplete;
+      checkbox = queryAndAssert<HTMLInputElement>(
+        query(
+          query(query(element, 'gr-change-list'), 'gr-change-list-section'),
+          'gr-change-list-item'
+        ),
+        '.selection > input'
+      );
+      assert.isTrue(checkbox.checked);
+    });
+  });
+
   test('computePage', () => {
     element.offset = 0;
     element.changesPerPage = 25;
@@ -92,7 +139,9 @@
   });
 
   test('prevArrow', async () => {
-    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     element.offset = 0;
     element.loading = false;
     await element.updateComplete;
@@ -104,32 +153,34 @@
   });
 
   test('nextArrow', async () => {
-    element.changes = _.times(
-      25,
-      _.constant({...createChange(), _more_changes: true})
-    ) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
     element.loading = false;
     await element.updateComplete;
     assert.isOk(query(element, '#nextArrow'));
 
-    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     await element.updateComplete;
     assert.isNotOk(query(element, '#nextArrow'));
   });
 
   test('handleNextPage', async () => {
     const showStub = sinon.stub(page, 'show');
-    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     element.changesPerPage = 10;
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
     assert.isFalse(showStub.called);
 
-    element.changes = _.times(
-      25,
-      _.constant({...createChange(), _more_changes: true})
-    ) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
@@ -139,7 +190,9 @@
   test('handlePreviousPage', async () => {
     const showStub = sinon.stub(page, 'show');
     element.offset = 0;
-    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     element.changesPerPage = 10;
     element.loading = false;
     await element.updateComplete;
@@ -233,6 +286,7 @@
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
         assert.equal(url, change);
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         assert.isTrue(opt!.redirect);
         promise.resolve();
       });
@@ -251,6 +305,7 @@
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
         assert.equal(url, change);
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         assert.isTrue(opt!.redirect);
         promise.resolve();
       });
@@ -265,6 +320,7 @@
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
         assert.equal(url, change);
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         assert.isTrue(opt!.redirect);
         promise.resolve();
       });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 6da552a..2f5965b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -32,11 +32,8 @@
   PreferencesInput,
 } from '../../../types/common';
 import {fire, fireEvent, fireReload} from '../../../utils/event-util';
-import {ScrollMode} from '../../../constants/constants';
-import {
-  getRequirements,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {ColumnNames, ScrollMode} from '../../../constants/constants';
+import {getRequirements} from '../../../utils/label-util';
 import {addGlobalShortcut, Key} from '../../../utils/dom-util';
 import {unique} from '../../../utils/common-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
@@ -48,22 +45,8 @@
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {queryAll} from '../../../utils/common-util';
 import {ValueChangedEvent} from '../../../types/events';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
-
-export const columnNames = [
-  'Subject',
-  // TODO(milutin) - remove once Submit Requirements are rolled out.
-  'Status',
-  'Owner',
-  'Reviewers',
-  'Comments',
-  'Repo',
-  'Branch',
-  'Updated',
-  'Size',
-  ' Status ', // spaces to differentiate from old 'Status'
-];
+import {Execution} from '../../../constants/reporting';
 
 export interface ChangeListSection {
   countLabel?: string;
@@ -132,7 +115,7 @@
    * properties should not be used together.
    */
   @property({type: Array})
-  sections: ChangeListSection[] = [];
+  sections?: ChangeListSection[] = [];
 
   @state() private dynamicHeaderEndpoints?: string[];
 
@@ -167,6 +150,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly shortcuts = new ShortcutController(this);
 
   private cursor = new GrCursorManager();
@@ -242,6 +227,7 @@
   }
 
   override render() {
+    if (!this.sections) return;
     const labelNames = this.computeLabelNames(this.sections);
     return html`
       <table id="changeList">
@@ -276,8 +262,8 @@
       >
         ${changeSection.emptyStateSlotName
           ? html`<slot
-              slot="${changeSection.emptyStateSlotName}"
-              name="${changeSection.emptyStateSlotName}"
+              slot=${changeSection.emptyStateSlotName}
+              name=${changeSection.emptyStateSlotName}
             ></slot>`
           : nothing}
       </gr-change-list-section>
@@ -291,7 +277,7 @@
       changedProperties.has('config') ||
       changedProperties.has('sections')
     ) {
-      this.computePreferences();
+      this.computeVisibleChangeTableColumns();
     }
 
     if (changedProperties.has('changes')) {
@@ -305,16 +291,13 @@
     }
   }
 
-  private computePreferences() {
+  private computeVisibleChangeTableColumns() {
     if (!this.config) return;
 
-    const changes = (this.sections ?? [])
-      .map(section => section.results)
-      .flat();
-    this.changeTableColumns = columnNames;
+    this.changeTableColumns = Object.values(ColumnNames);
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, this.config, changes)
+      this.isColumnEnabled(col, this.config)
     );
     if (this.account && this.preferences) {
       this.showNumber = !!this.preferences?.legacycid_in_change_table;
@@ -322,12 +305,19 @@
         this.preferences?.change_table &&
         this.preferences.change_table.length > 0
       ) {
-        const prefColumns = this.preferences.change_table.map(column =>
-          column === 'Project' ? 'Repo' : column
-        );
-        this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(col, this.config, changes)
-        );
+        const prefColumns = this.preferences.change_table
+          .map(column => (column === 'Project' ? ColumnNames.REPO : column))
+          .map(column =>
+            column === ColumnNames.STATUS ? ColumnNames.STATUS2 : column
+          );
+        this.reporting.reportExecution(Execution.USER_PREFERENCES_COLUMNS, {
+          statusColumn: prefColumns.includes(ColumnNames.STATUS2),
+        });
+        // Order visible column names by columnNames, filter only one that
+        // are in prefColumns and enabled by config
+        this.visibleChangeTableColumns = Object.values(ColumnNames)
+          .filter(col => prefColumns.includes(col))
+          .filter(col => this.isColumnEnabled(col, this.config));
       }
     }
   }
@@ -335,59 +325,29 @@
   /**
    * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(
-    column: string,
-    config?: ServerInfo,
-    changes?: ChangeInfo[]
-  ) {
-    if (!columnNames.includes(column)) return false;
+  isColumnEnabled(column: string, config?: ServerInfo) {
+    if (!Object.values(ColumnNames).includes(column as unknown as ColumnNames))
+      return false;
     if (!config || !config.change) return true;
     if (column === 'Comments')
       return this.flagsService.isEnabled('comments-column');
-    if (column === 'Status') {
-      return (changes ?? []).every(
-        change => !showNewSubmitRequirements(this.flagsService, change)
-      );
-    }
-    if (column === ' Status ')
-      return (changes ?? []).some(change =>
-        showNewSubmitRequirements(this.flagsService, change)
-      );
+    if (column === 'Status') return false;
+    if (column === ColumnNames.STATUS2) return true;
     return true;
   }
 
   // private but used in test
   computeLabelNames(sections: ChangeListSection[]) {
     if (!sections) return [];
-    let labels: string[] = [];
-    const nonExistingLabel = function (item: string) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) {
-        continue;
-      }
-      for (const change of section.results) {
-        if (!change.labels) {
-          continue;
-        }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
+    if (this.config?.submit_requirement_dashboard_columns?.length) {
+      return this.config?.submit_requirement_dashboard_columns;
     }
-
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      if (this.config?.submit_requirement_dashboard_columns?.length) {
-        return this.config?.submit_requirement_dashboard_columns;
-      } else {
-        const changes = sections.map(section => section.results).flat();
-        labels = (changes ?? [])
-          .map(change => getRequirements(change))
-          .flat()
-          .map(requirement => requirement.name)
-          .filter(unique);
-      }
-    }
+    const changes = sections.map(section => section.results).flat();
+    const labels = (changes ?? [])
+      .map(change => getRequirements(change))
+      .flat()
+      .map(requirement => requirement.name)
+      .filter(unique);
     return labels.sort();
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 365ba72..1e81123 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -27,11 +27,12 @@
   waitUntil,
 } from '../../../test/test-utils';
 import {Key} from '../../../utils/dom-util';
-import {TimeFormat} from '../../../constants/constants';
+import {ColumnNames, TimeFormat} from '../../../constants/constants';
 import {AccountId, NumericChangeId} from '../../../types/common';
 import {
   createChange,
   createServerInfo,
+  createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
@@ -165,23 +166,36 @@
             {
               ...createChange(),
               _number: 0 as NumericChangeId,
-              labels: {Verified: {approved: {}}},
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 1 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Code-Review': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Code-Review',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 2 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Library-Compliance': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Library-Compliance',
+                },
+              ],
             },
           ],
         },
@@ -207,7 +221,7 @@
         'Branch',
         'Updated',
         'Size',
-        ' Status ',
+        ColumnNames.STATUS2,
       ],
     };
     element.config = createServerInfo();
@@ -363,7 +377,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -401,7 +415,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -419,11 +433,23 @@
         }
       }
     });
+
+    test('show default order not preferences order', async () => {
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: ['Owner', 'Subject'],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+      assert.equal(element.visibleChangeTableColumns?.[0], 'Subject');
+      assert.equal(element.visibleChangeTableColumns?.[1], 'Owner');
+    });
   });
 
   test('obsolete column in preferences not visible', () => {
-    assert.isTrue(element._isColumnEnabled('Subject'));
-    assert.isFalse(element._isColumnEnabled('Assignee'));
+    assert.isTrue(element.isColumnEnabled('Subject'));
+    assert.isFalse(element.isColumnEnabled('Assignee'));
   });
 
   test('showStar and showNumber', async () => {
@@ -442,7 +468,7 @@
         'Branch',
         'Updated',
         'Size',
-        ' Status ',
+        ColumnNames.STATUS2,
       ],
     };
     element.config = createServerInfo();
@@ -492,4 +518,24 @@
       assert.isNotOk(query<HTMLElement>(element, '.bad'));
     });
   });
+
+  test('Show new status with feature flag', async () => {
+    stubFlags('isEnabled').returns(true);
+    element = basicFixture.instantiate();
+    element.sections = [{results: [{...createChange()}]}];
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      change_table: [
+        'Status', // old status
+      ],
+    };
+    element.config = createServerInfo();
+    await element.updateComplete;
+    assert.isTrue(
+      element.visibleChangeTableColumns?.includes(ColumnNames.STATUS2),
+      'Show new status'
+    );
+    const section = queryAndAssert(element, 'gr-change-list-section');
+    queryAndAssert<HTMLElement>(section, '.status');
+  });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
index 6c9fa68..c41ad70 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -78,11 +78,9 @@
             </li>
             <li>
               <p>If you are making a new commit use</p>
-              <gr-shell-command
-                .command="${Commands.CREATE}"
-              ></gr-shell-command>
+              <gr-shell-command .command=${Commands.CREATE}></gr-shell-command>
               <p>Or to amend an existing commit use</p>
-              <gr-shell-command .command="${Commands.AMEND}"></gr-shell-command>
+              <gr-shell-command .command=${Commands.AMEND}></gr-shell-command>
               <p>
                 Please make sure you add a commit message as it becomes the
                 description for your change.
@@ -91,7 +89,7 @@
             <li>
               <p>Push the change for code review</p>
               <gr-shell-command
-                .command="${Commands.PUSH_PREFIX + (this.branch ?? '[BRANCH]')}"
+                .command=${Commands.PUSH_PREFIX + (this.branch ?? '[BRANCH]')}
               ></gr-shell-command>
             </li>
             <li>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 1e43ee5..6929ca2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -119,25 +119,36 @@
 
   constructor() {
     super();
-    this.addEventListener('reload', () => this.reload(this.params));
-    // We are not currently verifying if the view is actually visible. We rely
-    // on gr-app-element to restamp the component if view changes
-    document.addEventListener('visibilitychange', () => {
-      if (document.visibilityState === 'visible') {
-        if (
-          Date.now() - this.lastVisibleTimestampMs >
-          RELOAD_DASHBOARD_INTERVAL_MS
-        )
-          this.reload(this.params);
-      } else {
-        this.lastVisibleTimestampMs = Date.now();
-      }
-    });
+    this.addEventListener('reload', () => this.reload());
   }
 
+  private readonly visibilityChangeListener = () => {
+    if (document.visibilityState === 'visible') {
+      if (
+        Date.now() - this.lastVisibleTimestampMs >
+        RELOAD_DASHBOARD_INTERVAL_MS
+      )
+        this.reload();
+    } else {
+      this.lastVisibleTimestampMs = Date.now();
+    }
+  };
+
   override connectedCallback() {
     super.connectedCallback();
     this.loadPreferences();
+    document.addEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+  }
+
+  override disconnectedCallback() {
+    document.removeEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    super.disconnectedCallback();
   }
 
   static override get styles() {
@@ -221,7 +232,7 @@
       <div class="banner">
         <div>
           You have draft comments on closed changes.
-          <a href="${this.computeDraftsLink()}" target="_blank">(view all)</a>
+          <a href=${this.computeDraftsLink()} target="_blank">(view all)</a>
         </div>
         <div>
           <gr-button
@@ -238,10 +249,12 @@
   }
 
   private renderContent() {
-    if (this.loading) return html`<div class="loading">Loading...</div>`;
-
+    // In case of an internal reload we want the ChangeList section components
+    // to remain in the DOM so that the Bulk Actions Model associated with them
+    // is not recreated after the reload resulting in user selections being lost
     return html`
-      <div>
+      <div class="loading" ?hidden=${!this.loading}>Loading...</div>
+      <div ?hidden=${this.loading}>
         ${this.renderUserHeader()}
         <h1 class="assistive-tech-only">Dashboard</h1>
         <gr-change-list
@@ -298,7 +311,7 @@
 
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('params')) {
-      this.paramsChanged(this.params);
+      this.paramsChanged();
     }
 
     if (changedProperties.has('selectedChangeIndex')) {
@@ -373,10 +386,15 @@
   }
 
   // private but used in test
-  paramsChanged(params?: AppElementDashboardParams) {
-    if (params && this.isViewActive(params) && params.user && this.viewState)
-      this.selectedChangeIndex = this.viewState[params.user] || 0;
-    return this.reload(params);
+  paramsChanged() {
+    if (
+      this.params &&
+      this.isViewActive(this.params) &&
+      this.params.user &&
+      this.viewState
+    )
+      this.selectedChangeIndex = this.viewState[this.params.user] || 0;
+    return this.reload();
   }
 
   /**
@@ -384,12 +402,12 @@
    *
    * private but used in test
    */
-  reload(params?: AppElementDashboardParams) {
-    if (!params || !this.isViewActive(params)) {
+  reload() {
+    if (!this.params || !this.isViewActive(this.params)) {
       return Promise.resolve();
     }
     this.loading = true;
-    const {project, dashboard, title, user, sections} = params;
+    const {project, dashboard, title, user, sections} = this.params;
     const dashboardPromise: Promise<UserDashboard | undefined> = project
       ? this.getProjectDashboard(project, dashboard)
       : Promise.resolve(
@@ -410,7 +428,7 @@
         return this.fetchDashboardChanges(res, checkForNewUser);
       })
       .then(() => {
-        this.maybeShowDraftsBanner(params);
+        this.maybeShowDraftsBanner();
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
@@ -454,31 +472,35 @@
       }
     }
 
-    return this.restApiService.getChanges(undefined, queries).then(changes => {
-      if (!changes) {
-        throw new Error('getChanges returns undefined');
-      }
-      if (checkForNewUser) {
-        // Last set of results is not meant for dashboard display.
-        const lastResultSet = changes.pop();
-        this.showNewUserHelp = lastResultSet!.length === 0;
-      }
-      this.results = changes
-        .map((results, i) => {
-          return {
-            name: res.sections[i].name,
-            countLabel: this.computeSectionCountLabel(results),
-            query: res.sections[i].query,
-            results: this.maybeSortResults(res.sections[i].name, results),
-            emptyStateSlotName: slotNameBySectionName.get(res.sections[i].name),
-          };
-        })
-        .filter(
-          (section, i) =>
-            i < res.sections.length &&
-            (!res.sections[i].hideIfEmpty || section.results.length)
-        );
-    });
+    return this.restApiService
+      .getChangesForMultipleQueries(undefined, queries)
+      .then(changes => {
+        if (!changes) {
+          throw new Error('getChanges returns undefined');
+        }
+        if (checkForNewUser) {
+          // Last set of results is not meant for dashboard display.
+          const lastResultSet = changes.pop();
+          this.showNewUserHelp = lastResultSet!.length === 0;
+        }
+        this.results = changes
+          .map((results, i) => {
+            return {
+              name: res.sections[i].name,
+              countLabel: this.computeSectionCountLabel(results),
+              query: res.sections[i].query,
+              results: this.maybeSortResults(res.sections[i].name, results),
+              emptyStateSlotName: slotNameBySectionName.get(
+                res.sections[i].name
+              ),
+            };
+          })
+          .filter(
+            (section, i) =>
+              i < res.sections.length &&
+              (!res.sections[i].hideIfEmpty || section.results.length)
+          );
+      });
   }
 
   /**
@@ -549,9 +571,9 @@
    *
    * private but used in test
    */
-  maybeShowDraftsBanner(params: AppElementDashboardParams) {
+  maybeShowDraftsBanner() {
     this.showDraftsBanner = false;
-    if (!(params.user === 'self')) {
+    if (!(this.params?.user === 'self')) {
       return;
     }
 
@@ -588,7 +610,7 @@
     this.confirmDeleteDialog.disabled = true;
     return this.restApiService.deleteDraftComments('-is:open').then(() => {
       this.closeConfirmDeleteOverlay();
-      this.reload(this.params);
+      this.reload();
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index 26f32e3..e5aff61 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -32,6 +32,8 @@
   mockPromise,
   queryAndAssert,
   query,
+  stubFlags,
+  waitUntil,
 } from '../../../test/test-utils';
 import {
   ChangeInfoId,
@@ -45,14 +47,19 @@
 import {GrCreateChangeHelp} from '../gr-create-change-help/gr-create-change-help';
 import {PageErrorEvent} from '../../../types/events';
 import {fixture, html} from '@open-wc/testing-helpers';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 
 suite('gr-dashboard-view tests', () => {
   let element: GrDashboardView;
 
   let paramsChangedPromise: Promise<any>;
-  let getChangesStub: sinon.SinonStub;
+  let getChangesStub: SinonStubbedMember<
+    RestApiService['getChangesForMultipleQueries']
+  >;
 
   setup(async () => {
+    getChangesStub = stubRestApi('getChangesForMultipleQueries');
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getAccountDetails').returns(
       Promise.resolve({
@@ -60,9 +67,6 @@
       })
     );
     stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
-    getChangesStub = stubRestApi('getChanges').callsFake((_, qs) =>
-      Promise.resolve((qs as string[]).map(() => []))
-    );
 
     element = await fixture<GrDashboardView>(html`
       <gr-dashboard-view></gr-dashboard-view>
@@ -76,27 +80,68 @@
     const paramsChanged = element.paramsChanged.bind(element);
     sinon
       .stub(element, 'paramsChanged')
-      .callsFake(params => paramsChanged(params).then(() => resolver()));
+      .callsFake(() => paramsChanged().then(() => resolver()));
+  });
+
+  suite('bulk actions', () => {
+    setup(async () => {
+      const sections = [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ];
+      getChangesStub.returns(Promise.resolve([[createChange()]]));
+      await element.fetchDashboardChanges({sections}, false);
+      element.loading = false;
+      stubFlags('isEnabled').returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('checkboxes remain checked after soft reload', async () => {
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        query(
+          query(query(element, 'gr-change-list'), 'gr-change-list-section'),
+          'gr-change-list-item'
+        ),
+        '.selection > input'
+      );
+      MockInteractions.tap(checkbox);
+      await waitUntil(() => checkbox.checked);
+
+      getChangesStub.restore();
+      getChangesStub.returns(Promise.resolve([[createChange()]]));
+
+      element.params = {
+        view: GerritView.DASHBOARD,
+        user: 'notself',
+        dashboard: '' as DashboardId,
+      };
+      await element.reload();
+      await element.updateComplete;
+      assert.isTrue(checkbox.checked);
+    });
   });
 
   suite('drafts banner functionality', () => {
     suite('maybeShowDraftsBanner', () => {
       test('not dashboard/self', () => {
-        element.maybeShowDraftsBanner({
+        element.params = {
           view: GerritView.DASHBOARD,
           user: 'notself',
           dashboard: '' as DashboardId,
-        });
+        };
+        element.maybeShowDraftsBanner();
         assert.isFalse(element.showDraftsBanner);
       });
 
       test('no drafts at all', () => {
         element.results = [];
-        element.maybeShowDraftsBanner({
+        element.params = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
-        });
+        };
+        element.maybeShowDraftsBanner();
         assert.isFalse(element.showDraftsBanner);
       });
 
@@ -105,11 +150,12 @@
         element.results = [
           {countLabel: '', name: '', query: 'has:draft', results: [openChange]},
         ];
-        element.maybeShowDraftsBanner({
+        element.params = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
-        });
+        };
+        element.maybeShowDraftsBanner();
         assert.isFalse(element.showDraftsBanner);
       });
 
@@ -123,12 +169,13 @@
             results: [notOpenChange],
           },
         ];
-        assert.isFalse(changeIsOpen(element.results![0].results[0]));
-        element.maybeShowDraftsBanner({
+        assert.isFalse(changeIsOpen(element.results[0].results[0]));
+        element.params = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
-        });
+        };
+        element.maybeShowDraftsBanner();
         assert.isTrue(element.showDraftsBanner);
       });
     });
@@ -373,8 +420,7 @@
       {name: 'test1', query: 'test1', hideIfEmpty: true},
       {name: 'test2', query: 'test2', hideIfEmpty: true},
     ];
-    getChangesStub.restore();
-    stubRestApi('getChanges').returns(Promise.resolve([[createChange()]]));
+    getChangesStub.returns(Promise.resolve([[createChange()]]));
 
     await element.fetchDashboardChanges({sections}, false);
     assert.equal(element.results!.length, 1);
@@ -386,8 +432,7 @@
       {name: 'Outgoing reviews', query: 'test1'},
       {name: 'test2', query: 'test2'},
     ];
-    getChangesStub.restore();
-    stubRestApi('getChanges').returns(Promise.resolve([[], []]));
+    getChangesStub.returns(Promise.resolve([[], []]));
 
     await element.fetchDashboardChanges({sections}, false);
     assert.equal(element.results!.length, 2);
@@ -530,6 +575,7 @@
         sections: [],
       })
     );
+    getChangesStub.returns(Promise.resolve([]));
     const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
     element.params = {
       view: GerritView.DASHBOARD,
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index d82ff7f..dec1656 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -62,7 +62,7 @@
     return html`<div>
       <span class="browse">Browse:</span>
       ${webLinks.map(
-        link => html`<a target="_blank" href="${link.url}">${link.name}</a> `
+        link => html`<a target="_blank" href=${link.url}>${link.name}</a> `
       )}
     </div> `;
   }
@@ -72,7 +72,7 @@
       <h1 class="heading-1">${this.repo}</h1>
       <hr />
       <div>
-        <span>Detail:</span> <a href="${this._repoUrl!}">Repo settings</a>
+        <span>Detail:</span> <a href=${this._repoUrl!}>Repo settings</a>
       </div>
       ${this._renderLinks(this._webLinks)}
     </div>`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 7265a7f..72f2a44 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -65,7 +65,7 @@
 
   override render() {
     return html`<gr-avatar
-        .account="${this._accountDetails}"
+        .account=${this._accountDetails}
         .imageSize=${100}
         aria-label="Account avatar"
       ></gr-avatar>
@@ -85,31 +85,31 @@
         <div>
           <span>Joined:</span>
           <gr-date-formatter
-            dateStr="${this._computeDetail(
+            dateStr=${this._computeDetail(
               this._accountDetails,
               'registered_on'
-            )}"
+            )}
           >
           </gr-date-formatter>
         </div>
         <gr-endpoint-decorator name="user-header">
           <gr-endpoint-param
             name="accountDetails"
-            .value="${this._accountDetails}"
+            .value=${this._accountDetails}
           >
           </gr-endpoint-param>
-          <gr-endpoint-param name="loggedIn" .value="${this.loggedIn}">
+          <gr-endpoint-param name="loggedIn" .value=${this.loggedIn}>
           </gr-endpoint-param>
         </gr-endpoint-decorator>
       </div>
       <div class="info">
         <div
-          class="${this._computeDashboardLinkClass(
+          class=${this._computeDashboardLinkClass(
             this.showDashboardLink,
             this.loggedIn
-          )}"
+          )}
         >
-          <a href="${this._computeDashboardUrl(this._accountDetails)}"
+          <a href=${this._computeDashboardUrl(this._accountDetails)}
             >View dashboard</a
           >
         </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 06a5a23..e9b7e2f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -29,7 +29,6 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {htmlTemplate} from './gr-change-actions_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
@@ -47,7 +46,6 @@
   NotifyType,
 } from '../../../constants/constants';
 import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountInfo,
   ActionInfo,
@@ -63,11 +61,9 @@
   LabelInfo,
   NumericChangeId,
   PatchSetNum,
-  PropertyType,
   RequestPayload,
   RevertSubmissionInfo,
   ReviewInput,
-  ServerInfo,
 } from '../../../types/common';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -86,13 +82,17 @@
   ConfirmRebaseEventDetail,
   GrConfirmRebaseDialog,
 } from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {
   GrChangeActionsElement,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {
+  fire,
+  fireAlert,
+  fireEvent,
+  fireReload,
+} from '../../../utils/event-util';
 import {
   getApprovalInfo,
   getVotingRange,
@@ -108,8 +108,13 @@
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {ifDefined} from 'lit/directives/if-defined';
+import {assertIsDefined, queryAll} from '../../../utils/common-util';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -320,35 +325,11 @@
   init?(): void;
 }
 
-export interface GrChangeActions {
-  $: {
-    mainContent: Element;
-    overlay: GrOverlay;
-    confirmRebase: GrConfirmRebaseDialog;
-    confirmCherrypick: GrConfirmCherrypickDialog;
-    confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
-    confirmMove: GrConfirmMoveDialog;
-    confirmRevertDialog: GrConfirmRevertDialog;
-    confirmAbandonDialog: GrConfirmAbandonDialog;
-    confirmSubmitDialog: GrConfirmSubmitDialog;
-    createFollowUpDialog: GrDialog;
-    createFollowUpChange: GrCreateChangeDialog;
-    confirmDeleteDialog: GrDialog;
-    confirmDeleteEditDialog: GrDialog;
-    moreActions: GrDropdown;
-    secondaryActions: HTMLElement;
-  };
-}
-
 @customElement('gr-change-actions')
 export class GrChangeActions
-  extends DIPolymerElement
+  extends LitElement
   implements GrChangeActionsElement
 {
-  static get template() {
-    return htmlTemplate;
-  }
-
   /**
    * Fired when the change should be reloaded.
    *
@@ -373,6 +354,37 @@
    * @event show-error
    */
 
+  @query('#mainContent') mainContent?: Element;
+
+  @query('#overlay') overlay?: GrOverlay;
+
+  @query('#confirmRebase') confirmRebase?: GrConfirmRebaseDialog;
+
+  @query('#confirmCherrypick') confirmCherrypick?: GrConfirmCherrypickDialog;
+
+  @query('#confirmCherrypickConflict')
+  confirmCherrypickConflict?: GrConfirmCherrypickConflictDialog;
+
+  @query('#confirmMove') confirmMove?: GrConfirmMoveDialog;
+
+  @query('#confirmRevertDialog') confirmRevertDialog?: GrConfirmRevertDialog;
+
+  @query('#confirmAbandonDialog') confirmAbandonDialog?: GrConfirmAbandonDialog;
+
+  @query('#confirmSubmitDialog') confirmSubmitDialog?: GrConfirmSubmitDialog;
+
+  @query('#createFollowUpDialog') createFollowUpDialog?: GrDialog;
+
+  @query('#createFollowUpChange') createFollowUpChange?: GrCreateChangeDialog;
+
+  @query('#confirmDeleteDialog') confirmDeleteDialog?: GrDialog;
+
+  @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog;
+
+  @query('#moreActions') moreActions?: GrDropdown;
+
+  @query('#secondaryActions') secondaryActions?: HTMLElement;
+
   // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
   // properties are replaced with enums everywhere and remove them from
   // the GrChangeActions class
@@ -407,8 +419,8 @@
   @property({type: Boolean})
   _hasKnownChainState = false;
 
-  @property({type: Boolean})
-  _hideQuickApproveAction = false;
+  // private but used in test
+  @state() _hideQuickApproveAction = false;
 
   @property({type: Object})
   account?: AccountInfo;
@@ -422,7 +434,7 @@
   @property({type: String})
   commitNum?: CommitId;
 
-  @property({type: Boolean, observer: '_computeChainState'})
+  @property({type: Boolean})
   hasParent?: boolean;
 
   @property({type: String})
@@ -431,58 +443,39 @@
   @property({type: String})
   commitMessage = '';
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   revisionActions: ActionNameToActionInfoMap = {};
 
-  @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
-  _revisionSubmitAction?: ActionInfo | null;
+  @state() private revisionSubmitAction?: ActionInfo | null;
 
-  @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
-  _revisionRebaseAction?: ActionInfo | null;
+  // used as a proprty type so cannot be private
+  @state() revisionRebaseAction?: ActionInfo | null;
 
   @property({type: String})
   privateByDefault?: InheritedBooleanInfo;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: String})
-  _actionLoadingMessage = '';
+  // private but used in test
+  @state() actionLoadingMessage = '';
 
-  @property({
-    type: Array,
-    computed:
-      '_computeAllActions(actions.*, revisionActions.*,' +
-      'primaryActionKeys.*, _additionalActions.*, change, ' +
-      '_actionPriorityOverrides.*)',
-  })
-  _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+  // _computeAllActions always returns an array
+  // private but used in test
+  @state() allActionValues: UIActionInfo[] = [];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeTopLevelActions(_allActionValues.*, ' +
-      '_hiddenActions.*, editMode, _overflowActions.*)',
-    observer: '_filterPrimaryActions',
-  })
-  _topLevelActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _topLevelPrimaryActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _topLevelSecondaryActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeMenuActions(_allActionValues.*, ' +
-      '_hiddenActions.*, _overflowActions.*)',
-  })
-  _menuActions?: MenuAction[];
+  @state() private menuActions?: MenuAction[];
 
-  @property({type: Array})
-  _overflowActions: OverflowAction[] = [
+  @state() private overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -529,17 +522,15 @@
     },
   ];
 
-  @property({type: Array})
-  _actionPriorityOverrides: ActionPriorityOverride[] = [];
+  @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @property({type: Array})
-  _additionalActions: UIActionInfo[] = [];
+  @state() private additionalActions: UIActionInfo[] = [];
 
-  @property({type: Array})
-  _hiddenActions: string[] = [];
+  // private but used in test
+  @state() hiddenActions: string[] = [];
 
-  @property({type: Array})
-  _disabledMenuActions: string[] = [];
+  // private but used in test
+  @state() disabledMenuActions: string[] = [];
 
   @property({type: Boolean})
   editPatchsetLoaded = false;
@@ -550,9 +541,6 @@
   @property({type: Boolean})
   editBasedOnCurrentPatchSet = true;
 
-  @property({type: Object})
-  _config?: ServerInfo;
-
   @property({type: Boolean})
   loggedIn = false;
 
@@ -563,31 +551,322 @@
   constructor() {
     super();
     this.addEventListener('fullscreen-overlay-opened', () =>
-      this._handleHideBackgroundContent()
+      this.handleHideBackgroundContent()
     );
     this.addEventListener('fullscreen-overlay-closed', () =>
-      this._handleShowBackgroundContent()
+      this.handleShowBackgroundContent()
     );
   }
 
-  override ready() {
-    super.ready();
+  override connectedCallback() {
+    super.connectedCallback();
     this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
+    this.handleLoadingComplete();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: flex;
+          font-family: var(--font-family);
+        }
+        #actionLoadingMessage,
+        #mainContent,
+        section {
+          display: flex;
+        }
+        #actionLoadingMessage,
+        gr-button,
+        gr-dropdown {
+          /* px because don't have the same font size */
+          margin-left: 8px;
+        }
+        gr-button {
+          display: block;
+        }
+        #actionLoadingMessage {
+          align-items: center;
+          color: var(--deemphasized-text-color);
+        }
+        #confirmSubmitDialog .changeSubject {
+          margin: var(--spacing-l);
+          text-align: center;
+        }
+        iron-icon {
+          color: inherit;
+          margin-right: var(--spacing-xs);
+        }
+        #moreActions iron-icon {
+          margin: 0;
+        }
+        #moreMessage,
+        .hidden {
+          display: none;
+        }
+        @media screen and (max-width: 50em) {
+          #mainContent {
+            flex-wrap: wrap;
+          }
+          gr-button {
+            --gr-button-padding: var(--spacing-m);
+            white-space: nowrap;
+          }
+          gr-button,
+          gr-dropdown {
+            margin: 0;
+          }
+          #actionLoadingMessage {
+            margin: var(--spacing-m);
+            text-align: center;
+          }
+          #moreMessage {
+            display: inline;
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.change) return nothing;
+    return html`
+      <div id="mainContent">
+        <span id="actionLoadingMessage" ?hidden=${!this.actionLoadingMessage}>
+          ${this.actionLoadingMessage}
+        </span>
+        <section
+          id="primaryActions"
+          ?hidden=${this.loading ||
+          !this.topLevelActions ||
+          !this.topLevelActions.length}
+        >
+          ${this.topLevelPrimaryActions?.map(action =>
+            this.renderTopPrimaryActions(action)
+          )}
+        </section>
+        <section
+          id="secondaryActions"
+          ?hidden=${this.loading ||
+          !this.topLevelActions ||
+          !this.topLevelActions.length}
+        >
+          ${this.topLevelSecondaryActions?.map(action =>
+            this.renderTopSecondaryActions(action)
+          )}
+        </section>
+        <gr-button ?hidden=${!this.loading}>Loading actions...</gr-button>
+        <gr-dropdown
+          id="moreActions"
+          link
+          .verticalOffset=${32}
+          .horizontalAlign=${'right'}
+          @tap-item=${this.handleOverflowItemTap}
+          ?hidden=${this.loading ||
+          !this.menuActions ||
+          !this.menuActions.length}
+          .disabledIds=${this.disabledMenuActions}
+          .items=${this.menuActions}
+        >
+          <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+          </iron-icon>
+          <span id="moreMessage">More</span>
+        </gr-dropdown>
+      </div>
+      <gr-overlay id="overlay" with-backdrop="">
+        <gr-confirm-rebase-dialog
+          id="confirmRebase"
+          class="confirmDialog"
+          .changeNumber=${this.change?._number}
+          @confirm=${this.handleRebaseConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .branch=${this.change?.branch}
+          .hasParent=${this.hasParent}
+          .rebaseOnCurrent=${this.revisionRebaseAction
+            ? !!this.revisionRebaseAction.enabled
+            : null}
+        ></gr-confirm-rebase-dialog>
+        <gr-confirm-cherrypick-dialog
+          id="confirmCherrypick"
+          class="confirmDialog"
+          .changeStatus=${this.changeStatus}
+          .commitMessage=${this.commitMessage}
+          .commitNum=${this.commitNum}
+          @confirm=${this.handleCherrypickConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .project=${this.change?.project}
+        ></gr-confirm-cherrypick-dialog>
+        <gr-confirm-cherrypick-conflict-dialog
+          id="confirmCherrypickConflict"
+          class="confirmDialog"
+          @confirm=${this.handleCherrypickConflictConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-cherrypick-conflict-dialog>
+        <gr-confirm-move-dialog
+          id="confirmMove"
+          class="confirmDialog"
+          @confirm=${this.handleMoveConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .project=${this.change?.project}
+        ></gr-confirm-move-dialog>
+        <gr-confirm-revert-dialog
+          id="confirmRevertDialog"
+          class="confirmDialog"
+          @confirm=${this.handleRevertDialogConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-revert-dialog>
+        <gr-confirm-abandon-dialog
+          id="confirmAbandonDialog"
+          class="confirmDialog"
+          @confirm=${this.handleAbandonDialogConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-abandon-dialog>
+        <gr-confirm-submit-dialog
+          id="confirmSubmitDialog"
+          class="confirmDialog"
+          .action=${this.revisionSubmitAction}
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleSubmitConfirm}
+        ></gr-confirm-submit-dialog>
+        <gr-dialog
+          id="createFollowUpDialog"
+          class="confirmDialog"
+          confirm-label="Create"
+          @confirm=${this.handleCreateFollowUpChange}
+          @cancel=${this.handleCloseCreateFollowUpChange}
+        >
+          <div class="header" slot="header">Create Follow-Up Change</div>
+          <div class="main" slot="main">
+            <gr-create-change-dialog
+              id="createFollowUpChange"
+              .branch=${this.change?.branch}
+              .baseChange=${this.change?.id}
+              .repoName=${this.change?.project}
+              .privateByDefault=${this.privateByDefault}
+            ></gr-create-change-dialog>
+          </div>
+        </gr-dialog>
+        <gr-dialog
+          id="confirmDeleteDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleDeleteConfirm}
+        >
+          <div class="header" slot="header">Delete Change</div>
+          <div class="main" slot="main">
+            Do you really want to delete the change?
+          </div>
+        </gr-dialog>
+        <gr-dialog
+          id="confirmDeleteEditDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleDeleteEditConfirm}
+        >
+          <div class="header" slot="header">Delete Change Edit</div>
+          <div class="main" slot="main">
+            Do you really want to delete the edit?
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderTopPrimaryActions(action: UIActionInfo) {
+    return html`
+      <gr-tooltip-content
+        title=${ifDefined(action.title)}
+        .hasTooltip=${!!action.title}
+        ?position-below=${true}
+      >
+        <gr-button
+          link
+          class=${action.__key}
+          data-action-key=${action.__key}
+          data-label=${action.label}
+          ?disabled=${this.calculateDisabled(action)}
+          @click=${(e: MouseEvent) =>
+            this.handleActionTap(e, action.__key, action.__type)}
+        >
+          <iron-icon
+            class=${action.icon ? '' : 'hidden'}
+            .icon="gr-icons:${action.icon}"
+          ></iron-icon>
+          ${action.label}
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderTopSecondaryActions(action: UIActionInfo) {
+    return html`
+      <gr-tooltip-content
+        title=${ifDefined(action.title)}
+        .hasTooltip=${!!action.title}
+        ?position-below=${true}
+      >
+        <gr-button
+          link
+          class=${action.__key}
+          data-action-key=${action.__key}
+          data-label=${action.label}
+          ?disabled=${this.calculateDisabled(action)}
+          @click=${(e: MouseEvent) =>
+            this.handleActionTap(e, action.__key, action.__type)}
+        >
+          <iron-icon
+            class=${action.icon ? '' : 'hidden'}
+            icon="gr-icons:${action.icon}"
+          ></iron-icon>
+          ${action.label}
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasParent')) {
+      this.computeChainState();
+    }
+
+    if (changedProperties.has('change')) {
+      this.reload();
+    }
+
+    this.editStatusChanged();
+
+    this.actionsChanged();
+    this.allActionValues = this.computeAllActions();
+    this.topLevelActions = this.allActionValues.filter(a => {
+      if (this.hiddenActions.includes(a.__key)) return false;
+      if (this.editMode) return EDIT_ACTIONS.has(a.__key);
+      return this.getActionOverflowIndex(a.__type, a.__key) === -1;
     });
-    this._handleLoadingComplete();
+    this.topLevelPrimaryActions = this.topLevelActions.filter(
+      action => action.__primary
+    );
+    this.topLevelSecondaryActions = this.topLevelActions.filter(
+      action => !action.__primary
+    );
+    this.menuActions = this.computeMenuActions();
+    this.revisionSubmitAction = this.getSubmitAction(this.revisionActions);
+    this.revisionRebaseAction = this.getRebaseAction(this.revisionActions);
   }
 
-  _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
-    return this._getRevisionAction(revisionActions, 'submit');
+  private getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+    return this.getRevisionAction(revisionActions, 'submit');
   }
 
-  _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
-    return this._getRevisionAction(revisionActions, 'rebase');
+  private getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+    return this.getRevisionAction(revisionActions, 'rebase');
   }
 
-  _getRevisionAction(
+  private getRevisionAction(
     revisionActions: ActionNameToActionInfoMap,
     actionName: string
   ) {
@@ -608,7 +887,7 @@
     }
     const change = this.change;
 
-    this._loading = true;
+    this.loading = true;
     return this.restApiService
       .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
       .then(revisionActions => {
@@ -617,37 +896,33 @@
         }
 
         this.revisionActions = revisionActions;
-        this._sendShowRevisionActions({
+        this.sendShowRevisionActions({
           change,
           revisionActions,
         });
-        this._handleLoadingComplete();
+        this.handleLoadingComplete();
       })
       .catch(err => {
         fireAlert(this, ERR_REVISION_ACTIONS);
-        this._loading = false;
+        this.loading = false;
         throw err;
       });
   }
 
-  _handleLoadingComplete() {
+  private handleLoadingComplete() {
     getPluginLoader()
       .awaitPluginsLoaded()
-      .then(() => (this._loading = false));
+      .then(() => (this.loading = false));
   }
 
-  _sendShowRevisionActions(detail: {
+  // private but used in test
+  sendShowRevisionActions(detail: {
     change: ChangeInfo;
     revisionActions: ActionNameToActionInfoMap;
   }) {
     this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
   }
 
-  @observe('change')
-  _changeChanged() {
-    this.reload();
-  }
-
   addActionButton(type: ActionType, label: string) {
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type: ${type}`);
@@ -659,16 +934,18 @@
       __key:
         ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
     };
-    this.push('_additionalActions', action);
+    this.additionalActions.push(action);
+    this.requestUpdate('additionalActions');
     return action.__key;
   }
 
   removeActionButton(key: string) {
-    const idx = this._indexOfActionButtonWithKey(key);
+    const idx = this.indexOfActionButtonWithKey(key);
     if (idx === -1) {
       return;
     }
-    this.splice('_additionalActions', idx, 1);
+    this.additionalActions.splice(idx, 1);
+    this.requestUpdate('additionalActions');
   }
 
   setActionButtonProp<T extends keyof UIActionInfo>(
@@ -676,26 +953,26 @@
     prop: T,
     value: UIActionInfo[T]
   ) {
-    this.set(
-      ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
-      value
-    );
+    this.additionalActions[this.indexOfActionButtonWithKey(key)][prop] = value;
+    this.requestUpdate('additionalActions');
   }
 
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type given: ${type}`);
     }
-    const index = this._getActionOverflowIndex(type, key);
+    const index = this.getActionOverflowIndex(type, key);
     const action: OverflowAction = {
       type,
       key,
       overflow,
     };
     if (!overflow && index !== -1) {
-      this.splice('_overflowActions', index, 1);
+      this.overflowActions.splice(index, 1);
+      this.requestUpdate('overflowActions');
     } else if (overflow) {
-      this.push('_overflowActions', action);
+      this.overflowActions.push(action);
+      this.requestUpdate('overflowActions');
     }
   }
 
@@ -707,7 +984,7 @@
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type given: ${type}`);
     }
-    const index = this._actionPriorityOverrides.findIndex(
+    const index = this.actionPriorityOverrides.findIndex(
       action => action.type === type && action.key === key
     );
     const action: ActionPriorityOverride = {
@@ -716,9 +993,11 @@
       priority,
     };
     if (index !== -1) {
-      this.set('_actionPriorityOverrides', index, action);
+      this.actionPriorityOverrides[index] = action;
+      this.requestUpdate('actionPriorityOverrides');
     } else {
-      this.push('_actionPriorityOverrides', action);
+      this.actionPriorityOverrides.push(action);
+      this.requestUpdate('actionPriorityOverrides');
     }
   }
 
@@ -731,11 +1010,13 @@
       throw Error(`Invalid action type given: ${type}`);
     }
 
-    const idx = this._hiddenActions.indexOf(key);
+    const idx = this.hiddenActions.indexOf(key);
     if (hidden && idx === -1) {
-      this.push('_hiddenActions', key);
+      this.hiddenActions.push(key);
+      this.requestUpdate('hiddenActions');
     } else if (!hidden && idx !== -1) {
-      this.splice('_hiddenActions', idx, 1);
+      this.hiddenActions.splice(idx, 1);
+      this.requestUpdate('hiddenActions');
     }
   }
 
@@ -749,182 +1030,111 @@
     }
   }
 
-  _indexOfActionButtonWithKey(key: string) {
-    for (let i = 0; i < this._additionalActions.length; i++) {
-      if (this._additionalActions[i].__key === key) {
+  private indexOfActionButtonWithKey(key: string) {
+    for (let i = 0; i < this.additionalActions.length; i++) {
+      if (this.additionalActions[i].__key === key) {
         return i;
       }
     }
     return -1;
   }
 
-  _shouldHideActions(
-    actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    loading?: boolean
-  ) {
-    return loading || !actions || !actions.base || !actions.base.length;
-  }
-
-  _keyCount(
-    changeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >
-  ) {
-    return Object.keys(changeRecord?.base || {}).length;
-  }
-
-  @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
-  _actionsChanged(
-    actionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    revisionActionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    additionalActionsChangeRecord?: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      actionsChangeRecord === undefined ||
-      revisionActionsChangeRecord === undefined ||
-      additionalActionsChangeRecord === undefined
-    ) {
-      return;
-    }
-
-    const additionalActions =
-      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
-      [];
+  private actionsChanged() {
     this.hidden =
-      this._keyCount(actionsChangeRecord) === 0 &&
-      this._keyCount(revisionActionsChangeRecord) === 0 &&
-      additionalActions.length === 0;
-    this._actionLoadingMessage = '';
-    this._actionLoadingMessage = '';
-    this._disabledMenuActions = [];
+      Object.keys(this.actions).length === 0 &&
+      Object.keys(this.revisionActions).length === 0 &&
+      this.additionalActions.length === 0;
+    this.actionLoadingMessage = '';
+    this.disabledMenuActions = [];
 
-    const revisionActions = revisionActionsChangeRecord.base || {};
-    if (Object.keys(revisionActions).length !== 0) {
-      if (!revisionActions.download) {
-        this.set('revisionActions.download', DOWNLOAD_ACTION);
+    if (Object.keys(this.revisionActions).length !== 0) {
+      if (!this.revisionActions.download) {
+        this.revisionActions = {
+          ...this.revisionActions,
+          download: DOWNLOAD_ACTION,
+        };
+        fire(this, 'revision-actions-changed', {
+          value: this.revisionActions,
+        });
       }
     }
-    const actions = actionsChangeRecord.base || {};
-    if (!actions.includedIn && this.change?.status === ChangeStatus.MERGED) {
-      this.set('actions.includedIn', INCLUDED_IN_ACTION);
-    }
-  }
-
-  _deleteAndNotify(actionName: string) {
-    if (this.actions && this.actions[actionName]) {
-      delete this.actions[actionName];
-      // We assign a fake value of 'false' to support Polymer 2
-      // see https://github.com/Polymer/polymer/issues/2631
-      this.notifyPath('actions.' + actionName, false);
-    }
-  }
-
-  @observe(
-    'editMode',
-    'editPatchsetLoaded',
-    'editBasedOnCurrentPatchSet',
-    'disableEdit',
-    'loggedIn',
-    'actions.*',
-    'change.*'
-  )
-  _editStatusChanged(
-    editMode: boolean,
-    editPatchsetLoaded: boolean,
-    editBasedOnCurrentPatchSet: boolean,
-    disableEdit: boolean,
-    loggedIn: boolean,
-    actionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
-  ) {
-    // Hide change edits if not logged in
     if (
-      actionsChangeRecord === undefined ||
-      changeChangeRecord === undefined ||
-      !loggedIn
+      !this.actions.includedIn &&
+      this.change?.status === ChangeStatus.MERGED
     ) {
+      this.actions = {...this.actions, includedIn: INCLUDED_IN_ACTION};
+    }
+  }
+
+  private editStatusChanged() {
+    // Hide change edits if not logged in
+    if (this.change === undefined || !this.loggedIn) {
       return;
     }
-    if (disableEdit) {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
-      this._deleteAndNotify('stopEdit');
-      this._deleteAndNotify('edit');
+    if (this.disableEdit) {
+      delete this.actions.rebaseEdit;
+      delete this.actions.publishEdit;
+      delete this.actions.deleteEdit;
+      delete this.actions.stopEdit;
+      delete this.actions.edit;
       return;
     }
-    const actions = actionsChangeRecord.base;
-    const change = changeChangeRecord.base;
-    if (actions && editPatchsetLoaded) {
+    if (this.editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
-      if (changeIsOpen(change)) {
-        if (editBasedOnCurrentPatchSet) {
-          if (!actions.publishEdit) {
-            this.set('actions.publishEdit', PUBLISH_EDIT);
+      if (changeIsOpen(this.change)) {
+        if (this.editBasedOnCurrentPatchSet) {
+          if (!this.actions.publishEdit) {
+            this.actions = {...this.actions, publishEdit: PUBLISH_EDIT};
           }
-          this._deleteAndNotify('rebaseEdit');
+          delete this.actions.rebaseEdit;
         } else {
-          if (!actions.rebaseEdit) {
-            this.set('actions.rebaseEdit', REBASE_EDIT);
+          if (!this.actions.rebaseEdit) {
+            this.actions = {...this.actions, rebaseEdit: REBASE_EDIT};
           }
-          this._deleteAndNotify('publishEdit');
+          delete this.actions.publishEdit;
         }
       }
-      if (!actions.deleteEdit) {
-        this.set('actions.deleteEdit', DELETE_EDIT);
+      if (!this.actions.deleteEdit) {
+        this.actions = {...this.actions, deleteEdit: DELETE_EDIT};
       }
     } else {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
+      delete this.actions.rebaseEdit;
+      delete this.actions.publishEdit;
+      delete this.actions.deleteEdit;
     }
 
-    if (actions && changeIsOpen(change)) {
+    if (changeIsOpen(this.change)) {
       // Only show edit button if there is no edit patchset loaded and the
       // file list is not in edit mode.
-      if (editPatchsetLoaded || editMode) {
-        this._deleteAndNotify('edit');
+      if (this.editPatchsetLoaded || this.editMode) {
+        delete this.actions.edit;
       } else {
-        if (!actions.edit) {
-          this.set('actions.edit', EDIT);
+        if (!this.actions.edit) {
+          this.actions = {...this.actions, edit: EDIT};
         }
       }
       // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
       // is loaded.
-      if (editMode && !editPatchsetLoaded) {
-        if (!actions.stopEdit) {
-          this.set('actions.stopEdit', STOP_EDIT);
+      if (this.editMode && !this.editPatchsetLoaded) {
+        if (!this.actions.stopEdit) {
+          this.actions = {...this.actions, stopEdit: STOP_EDIT};
           fireAlert(this, 'Change is in edit mode');
         }
       } else {
-        this._deleteAndNotify('stopEdit');
+        delete this.actions.stopEdit;
       }
     } else {
       // Remove edit button.
-      this._deleteAndNotify('edit');
+      delete this.actions.edit;
     }
   }
 
-  _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+  private getValuesFor<T>(obj: {[key: string]: T}): T[] {
     return Object.keys(obj).map(key => obj[key]);
   }
 
-  _getLabelStatus(label: LabelInfo): LabelStatus {
+  private getLabelStatus(label: LabelInfo): LabelStatus {
     if (isQuickLabelInfo(label)) {
       if (label.approved) {
         return LabelStatus.OK;
@@ -943,7 +1153,7 @@
    * Get highest score for last missing permitted label for current change.
    * Returns null if no labels permitted or more than one label missing.
    */
-  _getTopMissingApproval() {
+  private getTopMissingApproval() {
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
@@ -958,7 +1168,7 @@
       if (this.change.permitted_labels[label].length === 0) {
         continue;
       }
-      const status = this._getLabelStatus(labelInfo);
+      const status = this.getLabelStatus(labelInfo);
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so check if Code Review can be
@@ -1014,20 +1224,20 @@
   }
 
   hideQuickApproveAction() {
-    if (!this._topLevelSecondaryActions) {
-      throw new Error('_topLevelSecondaryActions must be set');
+    if (!this.topLevelSecondaryActions) {
+      throw new Error('topLevelSecondaryActions must be set');
     }
-    this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+    this.topLevelSecondaryActions = this.topLevelSecondaryActions.filter(
       sa => !isQuickApproveAction(sa)
     );
     this._hideQuickApproveAction = true;
   }
 
-  _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+  private getQuickApproveAction(): QuickApproveUIActionInfo | null {
     if (this._hideQuickApproveAction) {
       return null;
     }
-    const approval = this._getTopMissingApproval();
+    const approval = this.getTopMissingApproval();
     if (!approval) {
       return null;
     }
@@ -1049,32 +1259,23 @@
     return action;
   }
 
-  _getActionValues(
-    actionsChangeRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    primariesChangeRecord: PolymerDeepPropertyChange<
-      PrimaryActionKey[],
-      PrimaryActionKey[]
-    >,
-    additionalActionsChangeRecord: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >,
+  private getActionValues(
+    actionsChange: ActionNameToActionInfoMap,
+    primariesChange: PrimaryActionKey[],
+    additionalActionsChange: UIActionInfo[],
     type: ActionType
   ): UIActionInfo[] {
-    if (!actionsChangeRecord || !primariesChangeRecord) {
+    if (!actionsChange || !primariesChange) {
       return [];
     }
 
-    const actions = actionsChangeRecord.base || {};
-    const primaryActionKeys = primariesChangeRecord.base || [];
+    const actions = actionsChange;
+    const primaryActionKeys = primariesChange;
     const result: UIActionInfo[] = [];
     const values: Array<ChangeActions | RevisionActions> =
       type === ActionType.CHANGE
-        ? this._getValuesFor(ChangeActions)
-        : this._getValuesFor(RevisionActions);
+        ? this.getValuesFor(ChangeActions)
+        : this.getValuesFor(RevisionActions);
 
     const pluginActions: UIActionInfo[] = [];
     Object.keys(actions).forEach(a => {
@@ -1084,26 +1285,25 @@
       action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
       // Plugin actions always contain ~ in the key.
       if (a.indexOf('~') !== -1) {
-        this._populateActionUrl(action);
+        this.populateActionUrl(action);
         pluginActions.push(action);
         // Add server-side provided plugin actions to overflow menu.
-        this._overflowActions.push({
+        this.overflowActions.push({
           type,
           key: a,
         });
+        this.requestUpdate('overflowActions');
         return;
       } else if (!values.includes(a as PrimaryActionKey)) {
         return;
       }
-      action.label = this._getActionLabel(action);
+      action.label = this.getActionLabel(action);
 
       // Triggers a re-render by ensuring object inequality.
       result.push({...action});
     });
 
-    let additionalActions =
-      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
-      [];
+    let additionalActions = additionalActionsChange;
     additionalActions = additionalActions
       .filter(a => a.__type === type)
       .map(a => {
@@ -1114,7 +1314,7 @@
     return result.concat(additionalActions).concat(pluginActions);
   }
 
-  _populateActionUrl(action: UIActionInfo) {
+  private populateActionUrl(action: UIActionInfo) {
     const patchNum =
       action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
     if (!this.changeNum) {
@@ -1129,7 +1329,7 @@
    * Given a change action, return a display label that uses the appropriate
    * casing or includes explanatory details.
    */
-  _getActionLabel(action: UIActionInfo) {
+  private getActionLabel(action: UIActionInfo) {
     if (action.label === 'Delete') {
       // This label is common within change and revision actions. Make it more
       // explicit to the user.
@@ -1138,34 +1338,38 @@
       return 'Mark as work in progress';
     }
     // Otherwise, just map the name to sentence case.
-    return this._toSentenceCase(action.label);
+    return this.toSentenceCase(action.label);
   }
 
   /**
    * Capitalize the first letter and lowecase all others.
+   *
+   * private but used in test
    */
-  _toSentenceCase(s: string) {
+  toSentenceCase(s: string) {
     if (!s.length) {
       return '';
     }
     return s[0].toUpperCase() + s.slice(1).toLowerCase();
   }
 
-  _computeLoadingLabel(action: string) {
+  private computeLoadingLabel(action: string) {
     return ActionLoadingLabels[action] || 'Working...';
   }
 
-  _canSubmitChange() {
+  // private but used in test
+  canSubmitChange() {
     if (!this.change) {
       return false;
     }
     return this.jsAPI.canSubmitChange(
       this.change,
-      this._getRevision(this.change, this.latestPatchNum)
+      this.getRevision(this.change, this.latestPatchNum)
     );
   }
 
-  _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+  // private but used in test
+  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
     for (const rev of Object.values(change.revisions)) {
       if (rev._number === patchNum) {
         return rev;
@@ -1187,21 +1391,23 @@
         this.reporting.error(new Error('changes is undefined'));
         return;
       }
-      this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
-      this._showActionDialog(this.$.confirmRevertDialog);
+      assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+      this.confirmRevertDialog.populate(change, this.commitMessage, changes);
+      this.showActionDialog(this.confirmRevertDialog);
     });
   }
 
   showSubmitDialog() {
-    if (!this._canSubmitChange()) {
+    if (!this.canSubmitChange()) {
       return;
     }
-    this._showActionDialog(this.$.confirmSubmitDialog);
+    assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+    this.showActionDialog(this.confirmSubmitDialog);
   }
 
-  _handleActionTap(e: MouseEvent) {
+  private handleActionTap(e: MouseEvent, key: string, type: string) {
     e.preventDefault();
-    let el = (dom(e) as EventApi).localTarget as Element;
+    let el = e.target as Element;
     while (el.tagName.toLowerCase() !== 'gr-button') {
       if (!el.parentElement) {
         return;
@@ -1209,10 +1415,6 @@
       el = el.parentElement;
     }
 
-    const key = el.getAttribute('data-action-key');
-    if (!key) {
-      throw new Error("Button doesn't have data-action-key attribute");
-    }
     if (
       key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
       key.indexOf('~') !== -1
@@ -1226,11 +1428,10 @@
       );
       return;
     }
-    const type = el.getAttribute('data-action-type') as ActionType;
-    this._handleAction(type, key);
+    this.handleAction(type as ActionType, key);
   }
 
-  _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+  private handleOverflowItemTap(e: CustomEvent<MenuAction>) {
     e.preventDefault();
     const el = (dom(e) as EventApi).localTarget as Element;
     const key = e.detail.action.__key;
@@ -1247,147 +1448,160 @@
       );
       return;
     }
-    this._handleAction(e.detail.action.__type, e.detail.action.__key);
+    this.handleAction(e.detail.action.__type, e.detail.action.__key);
   }
 
-  _handleAction(type: ActionType, key: string) {
+  // private but used in test
+  handleAction(type: ActionType, key: string) {
     this.reporting.reportInteraction(`${type}-${key}`);
     switch (type) {
       case ActionType.REVISION:
-        this._handleRevisionAction(key);
+        this.handleRevisionAction(key);
         break;
       case ActionType.CHANGE:
-        this._handleChangeAction(key);
+        this.handleChangeAction(key);
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.actions[key]),
           false
         );
     }
   }
 
-  _handleChangeAction(key: string) {
+  // private but used in test
+  handleChangeAction(key: string) {
     switch (key) {
       case ChangeActions.REVERT:
         this.showRevertDialog();
         break;
       case ChangeActions.ABANDON:
-        this._showActionDialog(this.$.confirmAbandonDialog);
+        assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+        this.showActionDialog(this.confirmAbandonDialog);
         break;
       case QUICK_APPROVE_ACTION.key: {
-        const action = this._allActionValues.find(isQuickApproveAction);
+        const action = this.allActionValues.find(isQuickApproveAction);
         if (!action) {
           return;
         }
-        this._fireAction(this._prependSlash(key), action, true, action.payload);
+        this.fireAction(this.prependSlash(key), action, true, action.payload);
         break;
       }
       case ChangeActions.EDIT:
-        this._handleEditTap();
+        this.handleEditTap();
         break;
       case ChangeActions.STOP_EDIT:
-        this._handleStopEditTap();
+        this.handleStopEditTap();
         break;
       case ChangeActions.DELETE:
-        this._handleDeleteTap();
+        this.handleDeleteTap();
         break;
       case ChangeActions.DELETE_EDIT:
-        this._handleDeleteEditTap();
+        this.handleDeleteEditTap();
         break;
       case ChangeActions.FOLLOW_UP:
-        this._handleFollowUpTap();
+        this.handleFollowUpTap();
         break;
       case ChangeActions.WIP:
-        this._handleWipTap();
+        this.handleWipTap();
         break;
       case ChangeActions.MOVE:
-        this._handleMoveTap();
+        this.handleMoveTap();
         break;
       case ChangeActions.PUBLISH_EDIT:
-        this._handlePublishEditTap();
+        this.handlePublishEditTap();
         break;
       case ChangeActions.REBASE_EDIT:
-        this._handleRebaseEditTap();
+        this.handleRebaseEditTap();
         break;
       case ChangeActions.INCLUDED_IN:
-        this._handleIncludedInTap();
+        this.handleIncludedInTap();
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.actions[key]),
           false
         );
     }
   }
 
-  _handleRevisionAction(key: string) {
+  private handleRevisionAction(key: string) {
     switch (key) {
       case RevisionActions.REBASE:
-        this._showActionDialog(this.$.confirmRebase);
-        this.$.confirmRebase.fetchRecentChanges();
+        assertIsDefined(this.confirmRebase, 'confirmRebase');
+        this.showActionDialog(this.confirmRebase);
+        this.confirmRebase.fetchRecentChanges();
         break;
       case RevisionActions.CHERRYPICK:
-        this._handleCherrypickTap();
+        this.handleCherrypickTap();
         break;
       case RevisionActions.DOWNLOAD:
-        this._handleDownloadTap();
+        this.handleDownloadTap();
         break;
       case RevisionActions.SUBMIT:
-        if (!this._canSubmitChange()) {
+        if (!this.canSubmitChange()) {
           return;
         }
-        this._showActionDialog(this.$.confirmSubmitDialog);
+        assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+        this.showActionDialog(this.confirmSubmitDialog);
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.revisionActions[key]),
           true
         );
     }
   }
 
-  _prependSlash(key: string) {
+  private prependSlash(key: string) {
     return key === '/' ? key : `/${key}`;
   }
 
   /**
    * _hasKnownChainState set to true true if hasParent is defined (can be
    * either true or false). set to false otherwise.
+   *
+   * private but used in test
    */
-  _computeChainState() {
+  computeChainState() {
     this._hasKnownChainState = true;
   }
 
-  _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+  // private but used in test
+  calculateDisabled(action: UIActionInfo) {
     if (action.__key === 'rebase') {
       // Rebase button is only disabled when change has no parent(s).
-      return hasKnownChainState === false;
+      return this._hasKnownChainState === false;
     }
     return !action.enabled;
   }
 
-  _handleConfirmDialogCancel() {
-    this._hideAllDialogs();
+  private handleConfirmDialogCancel() {
+    this.hideAllDialogs();
   }
 
-  _hideAllDialogs() {
-    const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+  private hideAllDialogs() {
+    assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+    const dialogEls = queryAll(this, '.confirmDialog');
     for (const dialogEl of dialogEls) {
       (dialogEl as HTMLElement).hidden = true;
     }
-    this.$.overlay.close();
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.close();
   }
 
-  _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
-    const el = this.$.confirmRebase;
+  // private but used in test
+  handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+    assertIsDefined(this.confirmRebase, 'confirmRebase');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmRebase;
     const payload = {base: e.detail.base};
-    this.$.overlay.close();
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/rebase',
       assertUIActionInfo(this.revisionActions.rebase),
       true,
@@ -1395,16 +1609,20 @@
     );
   }
 
-  _handleCherrypickConfirm() {
-    this._handleCherryPickRestApi(false);
+  // private but used in test
+  handleCherrypickConfirm() {
+    this.handleCherryPickRestApi(false);
   }
 
-  _handleCherrypickConflictConfirm() {
-    this._handleCherryPickRestApi(true);
+  // private but used in test
+  handleCherrypickConflictConfirm() {
+    this.handleCherryPickRestApi(true);
   }
 
-  _handleCherryPickRestApi(conflicts: boolean) {
-    const el = this.$.confirmCherrypick;
+  private handleCherryPickRestApi(conflicts: boolean) {
+    assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmCherrypick;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
       return;
@@ -1413,9 +1631,9 @@
       fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
-    this.$.overlay.close();
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/cherrypick',
       assertUIActionInfo(this.revisionActions.cherrypick),
       true,
@@ -1428,29 +1646,34 @@
     );
   }
 
-  _handleMoveConfirm() {
-    const el = this.$.confirmMove;
+  // private but used in test
+  handleMoveConfirm() {
+    assertIsDefined(this.confirmMove, 'confirmMove');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmMove;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
-    this.$.overlay.close();
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+    this.fireAction('/move', assertUIActionInfo(this.actions.move), false, {
       destination_branch: el.branch,
       message: el.message,
     });
   }
 
-  _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+  private handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+    assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+    assertIsDefined(this.overlay, 'overlay');
     const revertType = e.detail.revertType;
     const message = e.detail.message;
-    const el = this.$.confirmRevertDialog;
-    this.$.overlay.close();
+    const el = this.confirmRevertDialog;
+    this.overlay.close();
     el.hidden = true;
     switch (revertType) {
       case RevertType.REVERT_SINGLE_CHANGE:
-        this._fireAction(
+        this.fireAction(
           '/revert',
           assertUIActionInfo(this.actions.revert),
           false,
@@ -1460,7 +1683,7 @@
       case RevertType.REVERT_SUBMISSION:
         // TODO(dhruvsri): replace with this.actions.revert_submission once
         // BE starts sending it again
-        this._fireAction(
+        this.fireAction(
           '/revert_submission',
           {__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo,
           false,
@@ -1472,11 +1695,14 @@
     }
   }
 
-  _handleAbandonDialogConfirm() {
-    const el = this.$.confirmAbandonDialog;
-    this.$.overlay.close();
+  // private but used in test
+  handleAbandonDialogConfirm() {
+    assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmAbandonDialog;
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/abandon',
       assertUIActionInfo(this.actions.abandon),
       false,
@@ -1486,58 +1712,62 @@
     );
   }
 
-  _handleCreateFollowUpChange() {
-    this.$.createFollowUpChange.handleCreateChange();
-    this._handleCloseCreateFollowUpChange();
+  private handleCreateFollowUpChange() {
+    assertIsDefined(this.createFollowUpChange, 'createFollowUpChange');
+    this.createFollowUpChange.handleCreateChange();
+    this.handleCloseCreateFollowUpChange();
   }
 
-  _handleCloseCreateFollowUpChange() {
-    this.$.overlay.close();
+  private handleCloseCreateFollowUpChange() {
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.close();
   }
 
-  _handleDeleteConfirm() {
-    this._hideAllDialogs();
-    this._fireAction(
+  private handleDeleteConfirm() {
+    this.hideAllDialogs();
+    this.fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
       false
     );
   }
 
-  _handleDeleteEditConfirm() {
-    this._hideAllDialogs();
+  private handleDeleteEditConfirm() {
+    this.hideAllDialogs();
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
     this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
 
-    this._fireAction(
+    this.fireAction(
       '/edit',
       assertUIActionInfo(this.actions.deleteEdit),
       false
     );
   }
 
-  _handleSubmitConfirm() {
-    if (!this._canSubmitChange()) {
+  // private but used in test
+  handleSubmitConfirm() {
+    if (!this.canSubmitChange()) {
       return;
     }
-    this._hideAllDialogs();
-    this._fireAction(
+    this.hideAllDialogs();
+    this.fireAction(
       '/submit',
       assertUIActionInfo(this.revisionActions.submit),
       true
     );
   }
 
-  _getActionOverflowIndex(type: string, key: string) {
-    return this._overflowActions.findIndex(
+  private getActionOverflowIndex(type: string, key: string) {
+    return this.overflowActions.findIndex(
       action => action.type === type && action.key === key
     );
   }
 
-  _setLoadingOnButtonWithKey(type: string, key: string) {
-    this._actionLoadingMessage = this._computeLoadingLabel(key);
+  // private but used in test
+  setLoadingOnButtonWithKey(type: string, key: string) {
+    this.actionLoadingMessage = this.computeLoadingLabel(key);
     let buttonKey = key;
     // TODO(dhruvsri): clean this up later
     // If key is revert-submission, then button key should be 'revert'
@@ -1547,14 +1777,12 @@
     }
 
     // If the action appears in the overflow menu.
-    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
-      this.push(
-        '_disabledMenuActions',
-        buttonKey === '/' ? 'delete' : buttonKey
-      );
+    if (this.getActionOverflowIndex(type, buttonKey) !== -1) {
+      this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
+      this.requestUpdate('disabledMenuActions');
       return () => {
-        this._actionLoadingMessage = '';
-        this._disabledMenuActions = [];
+        this.actionLoadingMessage = '';
+        this.disabledMenuActions = [];
       };
     }
 
@@ -1568,38 +1796,41 @@
     buttonEl.setAttribute('loading', 'true');
     buttonEl.disabled = true;
     return () => {
-      this._actionLoadingMessage = '';
+      this.actionLoadingMessage = '';
       buttonEl.removeAttribute('loading');
       buttonEl.disabled = false;
     };
   }
 
-  _fireAction(
+  // private but used in test
+  fireAction(
     endpoint: string,
     action: UIActionInfo,
     revAction: boolean,
     payload?: RequestPayload
   ) {
-    const cleanupFn = this._setLoadingOnButtonWithKey(
+    const cleanupFn = this.setLoadingOnButtonWithKey(
       action.__type,
       action.__key
     );
 
-    this._send(
+    this.send(
       action.method,
       payload,
       endpoint,
       revAction,
       cleanupFn,
       action
-    ).then(res => this._handleResponse(action, res));
+    ).then(res => this.handleResponse(action, res));
   }
 
-  _showActionDialog(dialog: ChangeActionDialog) {
-    this._hideAllDialogs();
+  // private but used in test
+  showActionDialog(dialog: ChangeActionDialog) {
+    this.hideAllDialogs();
     if (dialog.init) dialog.init();
     dialog.hidden = false;
-    this.$.overlay.open().then(() => {
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.open().then(() => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
       }
@@ -1608,7 +1839,8 @@
 
   // TODO(rmistry): Redo this after
   // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setReviewOnRevert(newChangeId: NumericChangeId) {
+  // private but used in test
+  setReviewOnRevert(newChangeId: NumericChangeId) {
     const review = this.jsAPI.getReviewPostRevert(this.change);
     if (!review) {
       return Promise.resolve(undefined);
@@ -1616,7 +1848,8 @@
     return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
   }
 
-  _handleResponse(action: UIActionInfo, response?: Response) {
+  // private but used in test
+  handleResponse(action: UIActionInfo, response?: Response) {
     if (!response) {
       return;
     }
@@ -1624,8 +1857,8 @@
       switch (action.__key) {
         case ChangeActions.REVERT: {
           const revertChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-          this._waitForChangeReachable(revertChangeInfo._number)
-            .then(() => this._setReviewOnRevert(revertChangeInfo._number))
+          this.waitForChangeReachable(revertChangeInfo._number)
+            .then(() => this.setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
               GerritNav.navigateToChange(revertChangeInfo);
             });
@@ -1633,11 +1866,9 @@
         }
         case RevisionActions.CHERRYPICK: {
           const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-          this._waitForChangeReachable(cherrypickChangeInfo._number).then(
-            () => {
-              GerritNav.navigateToChange(cherrypickChangeInfo);
-            }
-          );
+          this.waitForChangeReachable(cherrypickChangeInfo._number).then(() => {
+            GerritNav.navigateToChange(cherrypickChangeInfo);
+          });
           break;
         }
         case ChangeActions.DELETE:
@@ -1661,7 +1892,7 @@
           )
             return;
           /* If there is only 1 change then gerrit will automatically
-             redirect to that change */
+            redirect to that change */
           GerritNav.navigateToSearchQuery(
             `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
           );
@@ -1674,7 +1905,8 @@
     });
   }
 
-  _handleResponseError(
+  // private but used in test
+  handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
     body?: RequestPayload
@@ -1696,7 +1928,12 @@
         body &&
         !(body as CherryPickInput).allow_conflicts
       ) {
-        return this._showActionDialog(this.$.confirmCherrypickConflict);
+        assertIsDefined(
+          this.confirmCherrypickConflict,
+          'confirmCherrypickConflict'
+        );
+        this.showActionDialog(this.confirmCherrypickConflict);
+        return;
       }
     }
     return response.text().then(errText => {
@@ -1713,7 +1950,8 @@
     });
   }
 
-  _send(
+  // private but used in test
+  send(
     method: HttpMethod | undefined,
     payload: RequestPayload | undefined,
     actionEndpoint: string,
@@ -1723,7 +1961,7 @@
   ): Promise<Response | undefined> {
     const handleError: ErrorCallback = response => {
       cleanupFn.call(this);
-      this._handleResponseError(action, response, payload);
+      this.handleResponseError(action, response, payload);
     };
     const change = this.change;
     const changeNum = this.changeNum;
@@ -1773,11 +2011,13 @@
       });
   }
 
-  _handleCherrypickTap() {
+  // private but used in test
+  handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
     }
-    this.$.confirmCherrypick.branch = '' as BranchName;
+    assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+    this.confirmCherrypick.branch = '' as BranchName;
     const query = `topic: "${this.change.topic}"`;
     const options = listChangesOptionsToHex(
       ListChangesOption.MESSAGES,
@@ -1790,52 +2030,61 @@
           this.reporting.error(new Error('getChanges returns undefined'));
           return;
         }
-        this.$.confirmCherrypick.updateChanges(changes);
-        this._showActionDialog(this.$.confirmCherrypick);
+        this.confirmCherrypick!.updateChanges(changes);
+        this.showActionDialog(this.confirmCherrypick!);
       });
   }
 
-  _handleMoveTap() {
-    this.$.confirmMove.branch = '' as BranchName;
-    this.$.confirmMove.message = '';
-    this._showActionDialog(this.$.confirmMove);
+  // private but used in test
+  handleMoveTap() {
+    assertIsDefined(this.confirmMove, 'confirmMove');
+    this.confirmMove.branch = '' as BranchName;
+    this.confirmMove.message = '';
+    this.showActionDialog(this.confirmMove);
   }
 
-  _handleDownloadTap() {
+  // private but used in test
+  handleDownloadTap() {
     fireEvent(this, 'download-tap');
   }
 
-  _handleIncludedInTap() {
+  // private but used in test
+  handleIncludedInTap() {
     fireEvent(this, 'included-tap');
   }
 
-  _handleDeleteTap() {
-    this._showActionDialog(this.$.confirmDeleteDialog);
+  // private but used in test
+  handleDeleteTap() {
+    assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
+    this.showActionDialog(this.confirmDeleteDialog);
   }
 
-  _handleDeleteEditTap() {
-    this._showActionDialog(this.$.confirmDeleteEditDialog);
+  // private but used in test
+  handleDeleteEditTap() {
+    assertIsDefined(this.confirmDeleteEditDialog, 'confirmDeleteEditDialog');
+    this.showActionDialog(this.confirmDeleteEditDialog);
   }
 
-  _handleFollowUpTap() {
-    this._showActionDialog(this.$.createFollowUpDialog);
+  private handleFollowUpTap() {
+    assertIsDefined(this.createFollowUpDialog, 'createFollowUpDialog');
+    this.showActionDialog(this.createFollowUpDialog);
   }
 
-  _handleWipTap() {
+  private handleWipTap() {
     if (!this.actions.wip) {
       return;
     }
-    this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+    this.fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
   }
 
-  _handlePublishEditTap() {
+  private handlePublishEditTap() {
     if (!this.actions.publishEdit) return;
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
     this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
 
-    this._fireAction(
+    this.fireAction(
       '/edit:publish',
       assertUIActionInfo(this.actions.publishEdit),
       false,
@@ -1843,93 +2092,71 @@
     );
   }
 
-  _handleRebaseEditTap() {
+  private handleRebaseEditTap() {
     if (!this.actions.rebaseEdit) {
       return;
     }
-    this._fireAction(
+    this.fireAction(
       '/edit:rebase',
       assertUIActionInfo(this.actions.rebaseEdit),
       false
     );
   }
 
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
+  // private but used in test
+  handleHideBackgroundContent() {
+    assertIsDefined(this.mainContent, 'mainContent');
+    this.mainContent.classList.add('overlayOpen');
   }
 
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
+  // private but used in test
+  handleShowBackgroundContent() {
+    assertIsDefined(this.mainContent, 'mainContent');
+    this.mainContent.classList.remove('overlayOpen');
   }
 
   /**
    * Merge sources of change actions into a single ordered array of action
    * values.
    */
-  _computeAllActions(
-    changeActionsRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    revisionActionsRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    primariesRecord: PolymerDeepPropertyChange<
-      PrimaryActionKey[],
-      PrimaryActionKey[]
-    >,
-    additionalActionsRecord: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >,
-    change?: ChangeInfo
-  ): UIActionInfo[] {
+  private computeAllActions(): UIActionInfo[] {
     // Polymer 2: check for undefined
-    if (
-      [
-        changeActionsRecord,
-        revisionActionsRecord,
-        primariesRecord,
-        additionalActionsRecord,
-        change,
-      ].includes(undefined)
-    ) {
+    if (this.change === undefined) {
       return [];
     }
 
-    const revisionActionValues = this._getActionValues(
-      revisionActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
+    const revisionActionValues = this.getActionValues(
+      this.revisionActions,
+      this.primaryActionKeys,
+      this.additionalActions,
       ActionType.REVISION
     );
-    const changeActionValues = this._getActionValues(
-      changeActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
+    const changeActionValues = this.getActionValues(
+      this.actions,
+      this.primaryActionKeys,
+      this.additionalActions,
       ActionType.CHANGE
     );
-    const quickApprove = this._getQuickApproveAction();
+    const quickApprove = this.getQuickApproveAction();
     if (quickApprove) {
       changeActionValues.unshift(quickApprove);
     }
 
     return revisionActionValues
       .concat(changeActionValues)
-      .sort((a, b) => this._actionComparator(a, b))
+      .sort((a, b) => this.actionComparator(a, b))
       .map(action => {
         if (ACTIONS_WITH_ICONS.has(action.__key)) {
           action.icon = action.__key;
         }
         return action;
       })
-      .filter(action => !this._shouldSkipAction(action));
+      .filter(action => !this.shouldSkipAction(action));
   }
 
-  _getActionPriority(action: UIActionInfo) {
+  private getActionPriority(action: UIActionInfo) {
     if (action.__type && action.__key) {
-      const overrideAction = this._actionPriorityOverrides.find(
+      const overrideAction = this.actionPriorityOverrides.find(
         i => i.type === action.__type && i.key === action.__key
       );
 
@@ -1951,10 +2178,12 @@
 
   /**
    * Sort comparator to define the order of change actions.
+   *
+   * private but used in test
    */
-  _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+  actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
     const priorityDelta =
-      this._getActionPriority(actionA) - this._getActionPriority(actionB);
+      this.getActionPriority(actionA) - this.getActionPriority(actionB);
     // Sort by the button label if same priority.
     if (priorityDelta === 0) {
       return actionA.label > actionB.label ? 1 : -1;
@@ -1963,41 +2192,15 @@
     }
   }
 
-  _shouldSkipAction(action: UIActionInfo) {
+  private shouldSkipAction(action: UIActionInfo) {
     return SKIP_ACTION_KEYS.includes(action.__key);
   }
 
-  _computeTopLevelActions(
-    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
-    editMode: boolean
-  ): UIActionInfo[] {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base.filter(a => {
-      if (hiddenActions.includes(a.__key)) return false;
-      if (editMode) return EDIT_ACTIONS.has(a.__key);
-      return this._getActionOverflowIndex(a.__type, a.__key) === -1;
-    });
-  }
-
-  _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
-    this._topLevelPrimaryActions = _topLevelActions.filter(
-      action => action.__primary
-    );
-    this._topLevelSecondaryActions = _topLevelActions.filter(
-      action => !action.__primary
-    );
-  }
-
-  _computeMenuActions(
-    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
-  ): MenuAction[] {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base
+  private computeMenuActions(): MenuAction[] {
+    return this.allActionValues
       .filter(a => {
-        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-        return overflow && !hiddenActions.includes(a.__key);
+        const overflow = this.getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return overflow && !this.hiddenActions.includes(a.__key);
       })
       .map(action => {
         let key = action.__key;
@@ -2013,15 +2216,6 @@
       });
   }
 
-  _computeRebaseOnCurrent(
-    revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
-  ) {
-    if (revisionRebaseAction) {
-      return !!revisionRebaseAction.enabled;
-    }
-    return null;
-  }
-
   /**
    * Occasionally, a change created by a change action is not yet known to the
    * API for a brief time. Wait for the given change number to be recognized.
@@ -2029,8 +2223,9 @@
    * Returns a promise that resolves with true if a request is recognized, or
    * false if the change was never recognized after all attempts.
    *
+   * private but used in test
    */
-  _waitForChangeReachable(changeNum: NumericChangeId) {
+  waitForChangeReachable(changeNum: NumericChangeId) {
     let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
@@ -2056,24 +2251,19 @@
     });
   }
 
-  _handleEditTap() {
+  private handleEditTap() {
     this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
   }
 
-  _handleStopEditTap() {
+  private handleStopEditTap() {
     this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
   }
-
-  _computeHasTooltip(title?: string) {
-    return !!title;
-  }
-
-  _computeHasIcon(action: UIActionInfo) {
-    return action.icon ? '' : 'hidden';
-  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-actions': GrChangeActions;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
deleted file mode 100644
index 17ca7cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      font-family: var(--font-family);
-    }
-    #actionLoadingMessage,
-    #mainContent,
-    section {
-      display: flex;
-    }
-    #actionLoadingMessage,
-    gr-button,
-    gr-dropdown {
-      /* px because don't have the same font size */
-      margin-left: 8px;
-    }
-    gr-button {
-      display: block;
-    }
-    #actionLoadingMessage {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-    }
-    #confirmSubmitDialog .changeSubject {
-      margin: var(--spacing-l);
-      text-align: center;
-    }
-    iron-icon {
-      color: inherit;
-      margin-right: var(--spacing-xs);
-    }
-    #moreActions iron-icon {
-      margin: 0;
-    }
-    #moreMessage,
-    .hidden {
-      display: none;
-    }
-    @media screen and (max-width: 50em) {
-      #mainContent {
-        flex-wrap: wrap;
-      }
-      gr-button {
-        --gr-button-padding: var(--spacing-m);
-        white-space: nowrap;
-      }
-      gr-button,
-      gr-dropdown {
-        margin: 0;
-      }
-      #actionLoadingMessage {
-        margin: var(--spacing-m);
-        text-align: center;
-      }
-      #moreMessage {
-        display: inline;
-      }
-    }
-  </style>
-  <div id="mainContent">
-    <span id="actionLoadingMessage" hidden$="[[!_actionLoadingMessage]]">
-      [[_actionLoadingMessage]]</span
-    >
-    <section
-      id="primaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
-        <gr-tooltip-content
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-        >
-          <gr-button
-            link=""
-            data-action-key$="[[action.__key]]"
-            class$="[[action.__key]]"
-            data-action-type$="[[action.__type]]"
-            data-label$="[[action.label]]"
-            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-            on-click="_handleActionTap"
-          >
-            <iron-icon
-              class$="[[_computeHasIcon(action)]]"
-              icon$="gr-icons:[[action.icon]]"
-            ></iron-icon>
-            [[action.label]]
-          </gr-button>
-        </gr-tooltip-content>
-      </template>
-    </section>
-    <section
-      id="secondaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template
-        is="dom-repeat"
-        items="[[_topLevelSecondaryActions]]"
-        as="action"
-      >
-        <gr-tooltip-content
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-        >
-          <gr-button
-            link=""
-            data-action-key$="[[action.__key]]"
-            class$="[[action.__key]]"
-            data-action-type$="[[action.__type]]"
-            data-label$="[[action.label]]"
-            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-            on-click="_handleActionTap"
-          >
-            <iron-icon
-              class$="[[_computeHasIcon(action)]]"
-              icon$="gr-icons:[[action.icon]]"
-            ></iron-icon>
-            [[action.label]]
-          </gr-button>
-        </gr-tooltip-content>
-      </template>
-    </section>
-    <gr-button hidden$="[[!_loading]]" disabled=""
-      >Loading actions...</gr-button
-    >
-    <gr-dropdown
-      id="moreActions"
-      link=""
-      vertical-offset="32"
-      horizontal-align="right"
-      on-tap-item="_handleOverflowItemTap"
-      hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
-      disabled-ids="[[_disabledMenuActions]]"
-      items="[[_menuActions]]"
-    >
-      <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-      </iron-icon>
-      <span id="moreMessage">More</span>
-    </gr-dropdown>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-rebase-dialog
-      id="confirmRebase"
-      class="confirmDialog"
-      change-number="[[change._number]]"
-      on-confirm="_handleRebaseConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      branch="[[change.branch]]"
-      has-parent="[[hasParent]]"
-      rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
-      hidden=""
-    ></gr-confirm-rebase-dialog>
-    <gr-confirm-cherrypick-dialog
-      id="confirmCherrypick"
-      class="confirmDialog"
-      change-status="[[changeStatus]]"
-      commit-message="[[commitMessage]]"
-      commit-num="[[commitNum]]"
-      on-confirm="_handleCherrypickConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-cherrypick-dialog>
-    <gr-confirm-cherrypick-conflict-dialog
-      id="confirmCherrypickConflict"
-      class="confirmDialog"
-      on-confirm="_handleCherrypickConflictConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-cherrypick-conflict-dialog>
-    <gr-confirm-move-dialog
-      id="confirmMove"
-      class="confirmDialog"
-      on-confirm="_handleMoveConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-move-dialog>
-    <gr-confirm-revert-dialog
-      id="confirmRevertDialog"
-      class="confirmDialog"
-      on-confirm="_handleRevertDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-dialog>
-    <gr-confirm-abandon-dialog
-      id="confirmAbandonDialog"
-      class="confirmDialog"
-      on-confirm="_handleAbandonDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-abandon-dialog>
-    <gr-confirm-submit-dialog
-      id="confirmSubmitDialog"
-      class="confirmDialog"
-      action="[[_revisionSubmitAction]]"
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleSubmitConfirm"
-      hidden=""
-    ></gr-confirm-submit-dialog>
-    <gr-dialog
-      id="createFollowUpDialog"
-      class="confirmDialog"
-      confirm-label="Create"
-      on-confirm="_handleCreateFollowUpChange"
-      on-cancel="_handleCloseCreateFollowUpChange"
-    >
-      <div class="header" slot="header">Create Follow-Up Change</div>
-      <div class="main" slot="main">
-        <gr-create-change-dialog
-          id="createFollowUpChange"
-          branch="[[change.branch]]"
-          base-change="[[change.id]]"
-          repo-name="[[change.project]]"
-          private-by-default="[[privateByDefault]]"
-        ></gr-create-change-dialog>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleDeleteConfirm"
-    >
-      <div class="header" slot="header">Delete Change</div>
-      <div class="main" slot="main">
-        Do you really want to delete the change?
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteEditDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleDeleteEditConfirm"
-    >
-      <div class="header" slot="header">Delete Change Edit</div>
-      <div class="main" slot="main">Do you really want to delete the edit?</div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 403d3fd..01bc9ef 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -54,23 +54,28 @@
   TopicName,
 } from '../../../types/common';
 import {ActionType} from '../../../api/change-actions';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {getAppContext} from '../../../services/app-context';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRebaseDialog} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
 
 // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
 suite('gr-change-actions tests', () => {
   let element: GrChangeActions;
 
   suite('basic tests', () => {
-    setup(() => {
+    setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
         Promise.resolve({
           cherrypick: {
@@ -127,7 +132,9 @@
         .stub(getPluginLoader(), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
-      element = basicFixture.instantiate();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
       element.change = createChangeViewChange();
       element.changeNum = 42 as NumericChangeId;
       element.latestPatchNum = 2 as PatchSetNum;
@@ -144,61 +151,41 @@
       };
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
 
-      return element.reload();
+      await element.updateComplete;
+      await element.reload();
     });
 
     test('show-revision-actions event should fire', async () => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      const spy = sinon.spy(element, 'sendShowRevisionActions');
       element.reload();
-      await flush();
+      await element.updateComplete;
       assert.isTrue(spy.called);
     });
 
     test('primary and secondary actions split properly', () => {
       // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions!.length, 1);
-      assert.equal(element._topLevelPrimaryActions![0].label, 'Submit');
+      assert.equal(element.topLevelPrimaryActions!.length, 1);
+      assert.equal(element.topLevelPrimaryActions![0].label, 'Submit');
       assert.equal(
-        element._topLevelSecondaryActions!.length,
-        element._topLevelActions!.length - 1
+        element.topLevelSecondaryActions!.length,
+        element.topLevelActions!.length - 1
       );
     });
 
     test('revert submission action is skipped', () => {
       assert.equal(
-        element._allActionValues.filter(action => action.__key === 'submit')
+        element.allActionValues.filter(action => action.__key === 'submit')
           .length,
         1
       );
       assert.equal(
-        element._allActionValues.filter(
+        element.allActionValues.filter(
           action => action.__key === 'revert_submission'
         ).length,
         0
       );
     });
 
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(
-        element._shouldHideActions(
-          {base: [] as UIActionInfo[]} as PolymerDeepPropertyChange<
-            UIActionInfo[],
-            UIActionInfo[]
-          >,
-          false
-        )
-      );
-      assert.isFalse(
-        element._shouldHideActions(
-          {
-            base: [{__key: 'test'}] as UIActionInfo[],
-          } as PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-          false
-        )
-      );
-    });
-
     test('plugin revision actions', async () => {
       const stub = stubRestApi('getChangeActionURL').returns(
         Promise.resolve('the-url')
@@ -207,7 +194,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(
           element.changeNum,
@@ -229,7 +216,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(element.changeNum, undefined, '/plugin~action')
       );
@@ -266,7 +253,7 @@
     });
 
     test('hide revision action', async () => {
-      await flush();
+      await element.updateComplete;
       let buttonEl: Element | undefined = queryAndAssert(
         element,
         '[data-action-key="submit"]'
@@ -277,14 +264,8 @@
         element.RevisionActions.SUBMIT,
         true
       );
-      assert.lengthOf(element._hiddenActions, 1);
-      element.setActionHidden(
-        element.ActionType.REVISION,
-        element.RevisionActions.SUBMIT,
-        true
-      );
-      assert.lengthOf(element._hiddenActions, 1);
-      await flush();
+      assert.lengthOf(element.hiddenActions, 1);
+      await element.updateComplete;
       buttonEl = query(element, '[data-action-key="submit"]');
       assert.isNotOk(buttonEl);
 
@@ -293,31 +274,35 @@
         element.RevisionActions.SUBMIT,
         false
       );
-      await flush();
+      await element.updateComplete;
       buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
       assert.isFalse(buttonEl.hasAttribute('hidden'));
     });
 
     test('buttons exist', async () => {
-      element._loading = false;
-      await flush();
+      element.loading = false;
+      await element.updateComplete;
       const buttonEls = queryAll(element, 'gr-button');
-      const menuItems = element.$.moreActions.items;
+      const menuItems = queryAndAssert<GrDropdown>(
+        element,
+        '#moreActions'
+      ).items;
 
       // Total button number is one greater than the number of total actions
       // due to the existence of the overflow menu trigger.
       assert.equal(
-        buttonEls!.length + menuItems!.length,
-        element._allActionValues.length + 1
+        buttonEls.length + menuItems!.length,
+        element.allActionValues.length + 1
       );
       assert.isFalse(element.hidden);
     });
 
     test('delete buttons have explicit labels', async () => {
-      await flush();
-      const deleteItems = element.$.moreActions.items!.filter(item =>
-        item.id!.startsWith('delete')
-      );
+      await element.updateComplete;
+      const deleteItems = queryAndAssert<GrDropdown>(
+        element,
+        '#moreActions'
+      ).items!.filter(item => item.id!.startsWith('delete'));
       assert.equal(deleteItems.length, 1);
       assert.equal(deleteItems[0].name, 'Delete change');
     });
@@ -335,10 +320,10 @@
           rev2: revObj,
         },
       };
-      assert.deepEqual(element._getRevision(change, 2 as PatchSetNum), revObj);
+      assert.deepEqual(element.getRevision(change, 2 as PatchSetNum), revObj);
     });
 
-    test('_actionComparator sort order', () => {
+    test('actionComparator sort order', () => {
       const actions = [
         {label: '123', __type: ActionType.CHANGE, __key: 'review'},
         {label: 'abc-ro', __type: ActionType.REVISION, __key: 'random'},
@@ -354,16 +339,18 @@
 
       const result = actions.slice();
       result.reverse();
-      result.sort(element._actionComparator.bind(element));
+      result.sort(element.actionComparator.bind(element));
       assert.deepEqual(result, actions);
     });
 
     test('submit change', async () => {
-      const showSpy = sinon.spy(element, '_showActionDialog');
+      const showSpy = sinon.spy(element, 'showActionDialog');
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      sinon
+        .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
+        .returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -373,25 +360,36 @@
       };
       element.latestPatchNum = 2 as PatchSetNum;
 
-      const submitButton = queryAndAssert(
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"]'
-      );
-      tap(submitButton);
+      ).click();
 
-      await flush();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+      await element.updateComplete;
+      assert.isTrue(
+        showSpy.calledWith(
+          queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+        )
+      );
     });
 
     test('submit change, tap on icon', async () => {
       const submitted = mockPromise();
       sinon
-        .stub(element.$.confirmSubmitDialog, 'resetFocus')
+        .stub(
+          queryAndAssert<GrConfirmSubmitDialog>(
+            element,
+            '#confirmSubmitDialog'
+          ),
+          'resetFocus'
+        )
         .callsFake(() => submitted.resolve());
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      sinon
+        .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
+        .returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -401,18 +399,17 @@
       };
       element.latestPatchNum = 2 as PatchSetNum;
 
-      const submitIcon = queryAndAssert(
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"] iron-icon'
-      );
-      tap(submitIcon);
+      ).click();
       await submitted;
     });
 
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
+    test('handleSubmitConfirm', () => {
+      const fireStub = sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'canSubmitChange').returns(true);
+      element.handleSubmitConfirm();
       assert.isTrue(fireStub.calledOnce);
       assert.deepEqual(fireStub.lastCall.args, [
         '/submit',
@@ -421,77 +418,66 @@
       ]);
     });
 
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
+    test('handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'canSubmitChange').returns(false);
+      element.handleSubmitConfirm();
       assert.isFalse(fireStub.called);
     });
 
     test('submit change with plugin hook', async () => {
-      sinon.stub(element, '_canSubmitChange').callsFake(() => false);
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      await flush();
-      const submitButton = queryAndAssert(
+      sinon.stub(element, 'canSubmitChange').callsFake(() => false);
+      const fireActionStub = sinon.stub(element, 'fireAction');
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"]'
-      );
-      tap(submitButton);
+      ).click();
       assert.equal(fireActionStub.callCount, 0);
     });
 
-    test('chain state', () => {
+    test('chain state', async () => {
       assert.equal(element._hasKnownChainState, false);
       element.hasParent = true;
+      await element.updateComplete;
       assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
     });
 
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
+    test('calculateDisabled', () => {
       const action = {
         __key: 'rebase',
         enabled: true,
         __type: ActionType.CHANGE,
         label: 'l',
       };
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        true
-      );
+      element._hasKnownChainState = false;
+      assert.equal(element.calculateDisabled(action), true);
 
       action.__key = 'delete';
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      assert.equal(element.calculateDisabled(action), false);
 
       action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      element._hasKnownChainState = true;
+      assert.equal(element.calculateDisabled(action), false);
 
       action.enabled = false;
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      assert.equal(element.calculateDisabled(action), false);
     });
 
     test('rebase change', async () => {
-      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fireActionStub = sinon.stub(element, 'fireAction');
       const fetchChangesStub = sinon
-        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .stub(
+          queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+          'fetchRecentChanges'
+        )
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
-      );
-      tap(rebaseButton);
+      ).click();
       const rebaseAction = {
         __key: 'rebase',
         __type: 'revision',
@@ -502,7 +488,7 @@
         title: 'Rebase onto tip of branch or parent change',
       };
       assert.isTrue(fetchChangesStub.called);
-      element._handleRebaseConfirm(
+      element.handleRebaseConfirm(
         new CustomEvent('', {detail: {base: '1234'}})
       );
       assert.deepEqual(fireActionStub.lastCall.args, [
@@ -515,87 +501,108 @@
 
     test('rebase change fires reload event', async () => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
-      element._handleResponse(
+      await element.handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      await flush();
       assert.isTrue(eventStub.called);
       assert.equal(eventStub.lastCall.args[0].type, 'reload');
     });
 
     test("rebase dialog gets recent changes each time it's opened", async () => {
       const fetchChangesStub = sinon
-        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .stub(
+          queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+          'fetchRecentChanges'
+        )
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      const rebaseButton = queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
       );
-      tap(rebaseButton);
+      rebaseButton.click();
+      await element.updateComplete;
       assert.isTrue(fetchChangesStub.calledOnce);
 
-      await flush();
-      element.$.confirmRebase.dispatchEvent(
+      await element.updateComplete;
+      queryAndAssert<GrConfirmRebaseDialog>(
+        element,
+        '#confirmRebase'
+      ).dispatchEvent(
         new CustomEvent('cancel', {
           composed: true,
           bubbles: true,
         })
       );
-      tap(rebaseButton);
+      rebaseButton.click();
       assert.isTrue(fetchChangesStub.calledTwice);
     });
 
     test('two dialogs are not shown at the same time', async () => {
       element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
+      ).click();
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
       );
-      tap(rebaseButton);
-      await flush();
-      assert.isFalse(element.$.confirmRebase.hidden);
       stubRestApi('getChanges').returns(Promise.resolve([]));
-      element._handleCherrypickTap();
-      await flush();
-      assert.isTrue(element.$.confirmRebase.hidden);
-      assert.isFalse(element.$.confirmCherrypick.hidden);
+      element.handleCherrypickTap();
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
+      );
+      assert.isFalse(
+        queryAndAssert<GrConfirmCherrypickDialog>(element, '#confirmCherrypick')
+          .hidden
+      );
     });
 
     test('fullscreen-overlay-opened hides content', () => {
-      const spy = sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
+      const spy = sinon.spy(element, 'handleHideBackgroundContent');
+      queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
         new CustomEvent('fullscreen-overlay-opened', {
           composed: true,
           bubbles: true,
         })
       );
       assert.isTrue(spy.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.isTrue(
+        queryAndAssert<Element>(element, '#mainContent').classList.contains(
+          'overlayOpen'
+        )
+      );
     });
 
     test('fullscreen-overlay-closed shows content', () => {
-      const spy = sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
+      const spy = sinon.spy(element, 'handleShowBackgroundContent');
+      queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
         new CustomEvent('fullscreen-overlay-closed', {
           composed: true,
           bubbles: true,
         })
       );
       assert.isTrue(spy.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.isFalse(
+        queryAndAssert<Element>(element, '#mainContent').classList.contains(
+          'overlayOpen'
+        )
+      );
     });
 
-    test('_setReviewOnRevert', () => {
+    test('setReviewOnRevert', () => {
       const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
       const changeId = 1234 as NumericChangeId;
       sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
       const saveStub = stubRestApi('saveChangeReview').returns(
         Promise.resolve(new Response())
       );
-      const setReviewOnRevert = element._setReviewOnRevert(changeId) as Promise<
+      const setReviewOnRevert = element.setReviewOnRevert(changeId) as Promise<
         undefined | Response
       >;
       return setReviewOnRevert.then((_res: Response | undefined) => {
@@ -607,14 +614,14 @@
 
     suite('change edits', () => {
       test('disableEdit', async () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
+        element.editMode = false;
+        element.editBasedOnCurrentPatchSet = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        element.set('disableEdit', true);
-        await flush();
+        element.disableEdit = true;
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -630,28 +637,30 @@
       });
 
       test('shows confirm dialog for delete edit', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
+        await element.updateComplete;
 
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteEditDialog'),
-            'gr-button[primary]'
-          )
+        const fireActionStub = sinon.stub(element, 'fireAction');
+        element.handleDeleteEditTap();
+        assert.isFalse(
+          queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
         );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteEditDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
       });
 
       test('all cached change edits get deleted on delete edit', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
+        await element.updateComplete;
 
         const storage = getAppContext().storageService;
         storage.setEditableContentItem(
@@ -679,31 +688,31 @@
         const eraseEditableContentItemsForChangeEditSpy = spyStorage(
           'eraseEditableContentItemsForChangeEdit'
         );
-        sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteEditDialog'),
-            'gr-button[primary]'
-          )
+        sinon.stub(element, 'fireAction');
+        element.handleDeleteEditTap();
+        assert.isFalse(
+          queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
         );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteEditDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(eraseEditableContentItemsForChangeEditSpy.called);
         assert.isNotOk(storage.getEditableContentItem('c42_psedit_index.php')!);
         assert.isNotOk(storage.getEditableContentItem('c42_ps2_index.php')!);
       });
 
       test('edit patchset is loaded, needs rebase', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = false;
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -715,15 +724,15 @@
       });
 
       test('edit patchset is loaded, does not need rebase', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = true;
-        await flush();
+        await element.updateComplete;
 
         assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
         assert.isNotOk(
@@ -735,14 +744,14 @@
       });
 
       test('edit mode is loaded, no edit patchset', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -758,14 +767,14 @@
       });
 
       test('normal patch set', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
+        element.loggedIn = true;
+        element.editMode = false;
+        element.editPatchsetLoaded = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -781,17 +790,17 @@
       });
 
       test('edit action', async () => {
-        element.set('loggedIn', true);
+        element.loggedIn = true;
         const editTapped = mockPromise();
         element.addEventListener('edit-tap', () => {
           editTapped.resolve();
         });
-        element.set('editMode', true);
+        element.editMode = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
@@ -799,34 +808,33 @@
           ...createChangeViewChange(),
           status: ChangeStatus.MERGED,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        element.set('editMode', false);
-        await flush();
+        element.editMode = false;
+        await element.updateComplete;
 
-        const editButton = queryAndAssert(
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="edit"]'
-        );
-        tap(editButton);
+        ).click();
         await editTapped;
       });
     });
 
     test('edit action not shown for logged out user', async () => {
-      element.set('loggedIn', false);
-      element.set('editMode', false);
-      element.set('editPatchsetLoaded', false);
+      element.loggedIn = false;
+      element.editMode = false;
+      element.editPatchsetLoaded = false;
       element.change = {
         ...createChangeViewChange(),
         status: ChangeStatus.NEW,
       };
-      await flush();
+      await element.updateComplete;
 
       assert.isNotOk(
         query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -841,12 +849,12 @@
       let fireActionStub: sinon.SinonStub;
 
       setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+        fireActionStub = sinon.stub(element, 'fireAction');
         sinon.stub(window, 'alert');
       });
 
-      test('works', () => {
-        element._handleCherrypickTap();
+      test('works', async () => {
+        element.handleCherrypickTap();
         const action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -857,24 +865,41 @@
           title: 'Cherry pick change to a different branch',
         };
 
-        element._handleCherrypickConfirm();
+        element.handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0);
 
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
-        element._handleCherrypickConfirm();
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
+        element.handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0); // Still needs a message.
 
         // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
-        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitMessage = 'foo message';
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).changeStatus = ChangeStatus.NEW;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitNum = '123' as CommitId;
+        await element.updateComplete;
 
-        element._handleCherrypickConfirm();
+        element.handleCherrypickConfirm();
+        await element.updateComplete;
 
-        const autogrowEl = queryAndAssert(
-          element.$.confirmCherrypick,
+        const autogrowEl = queryAndAssert<IronAutogrowTextareaElement>(
+          queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          ),
           '#messageInput'
-        ) as IronAutogrowTextareaElement;
+        );
         assert.equal(autogrowEl.value, 'foo message');
 
         assert.deepEqual(fireActionStub.lastCall.args, [
@@ -890,8 +915,8 @@
         ]);
       });
 
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
+      test('cherry pick even with conflicts', async () => {
+        element.handleCherrypickTap();
         const action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -902,14 +927,28 @@
           title: 'Cherry pick change to a different branch',
         };
 
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
 
         // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
-        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitMessage = 'foo message';
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).changeStatus = ChangeStatus.NEW;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitNum = '123' as CommitId;
+        await element.updateComplete;
 
-        element._handleCherrypickConflictConfirm();
+        element.handleCherrypickConflictConfirm();
+        await element.updateComplete;
 
         assert.deepEqual(fireActionStub.lastCall.args, [
           '/cherrypick',
@@ -926,10 +965,19 @@
 
       test('branch name cleared when re-open cherrypick', () => {
         const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
 
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+        element.handleCherrypickTap();
+        assert.equal(
+          queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          ).branch,
+          emptyBranchName
+        );
       });
 
       suite('cherry pick topics', () => {
@@ -953,20 +1001,28 @@
         ];
         setup(async () => {
           stubRestApi('getChanges').returns(Promise.resolve(changes));
-          element._handleCherrypickTap();
-          await flush();
-          const radioButtons = queryAll(
-            element.$.confirmCherrypick,
+          element.handleCherrypickTap();
+          await element.updateComplete;
+          const confirmCherrypick = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          await element.updateComplete;
+          const radioButtons = queryAll<HTMLInputElement>(
+            confirmCherrypick,
             "input[name='cherryPickOptions']"
           );
           assert.equal(radioButtons.length, 2);
-          tap(radioButtons[1]);
-          await flush();
+          radioButtons[1].click();
+          await element.updateComplete;
         });
 
         test('cherry pick topic dialog is rendered', async () => {
-          const dialog = element.$.confirmCherrypick;
-          await flush();
+          const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          await element.updateComplete;
           const changesTable = queryAndAssert(dialog, 'table');
           const headers = Array.from(changesTable.querySelectorAll('th'));
           const expectedHeadings = [
@@ -1002,11 +1058,14 @@
         });
 
         test('changes with duplicate project show an error', async () => {
-          const dialog = element.$.confirmCherrypick;
-          const error = queryAndAssert(
+          const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          const error = queryAndAssert<HTMLSpanElement>(
             dialog,
             '.error-message'
-          ) as HTMLSpanElement;
+          );
           assert.equal(error.innerText, '');
           dialog.updateChanges([
             {
@@ -1024,7 +1083,7 @@
               project: 'A' as RepoName,
             },
           ]);
-          await flush();
+          await element.updateComplete;
           assert.equal(
             error.innerText,
             'Two changes cannot be of the same' + ' project'
@@ -1036,8 +1095,8 @@
     suite('move change', () => {
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         sinon.stub(window, 'alert');
         element.actions = {
           move: {
@@ -1047,25 +1106,31 @@
             enabled: true,
           },
         };
+        await element.updateComplete;
       });
 
       test('works', () => {
-        element._handleMoveTap();
+        element.handleMoveTap();
 
-        element._handleMoveConfirm();
+        element.handleMoveConfirm();
         assert.equal(fireActionStub.callCount, 0);
 
-        element.$.confirmMove.branch = 'master' as BranchName;
-        element._handleMoveConfirm();
+        queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+          'master' as BranchName;
+        element.handleMoveConfirm();
         assert.equal(fireActionStub.callCount, 1);
       });
 
       test('branch name cleared when re-open move', () => {
         const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+          'master' as BranchName;
 
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+        element.handleMoveTap();
+        assert.equal(
+          queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch,
+          emptyBranchName
+        );
       });
     });
 
@@ -1080,25 +1145,29 @@
           key
         );
         element.removeActionButton(key);
-        await flush();
+        await element.updateComplete;
         assert.notOk(query(element, '[data-action-key="' + key + '"]'));
         keyTapped.resolve();
       });
-      await flush();
-      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await element.updateComplete;
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
+        element,
+        '[data-action-key="' + key + '"]'
+      ).click();
       await keyTapped;
     });
 
-    test('_setLoadingOnButtonWithKey top-level', () => {
+    test('setLoadingOnButtonWithKey top-level', () => {
       const key = 'rebase';
       const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      assert.equal(element.actionLoadingMessage, 'Rebasing...');
 
-      const button = queryAndAssert(
+      const button = queryAndAssert<GrButton>(
         element,
         '[data-action-key="' + key + '"]'
-      ) as GrButton;
+      );
       assert.isTrue(button.hasAttribute('loading'));
       assert.isTrue(button.disabled);
 
@@ -1108,29 +1177,29 @@
 
       assert.isFalse(button.hasAttribute('loading'));
       assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
+      assert.isNotOk(element.actionLoadingMessage);
     });
 
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
+    test('setLoadingOnButtonWithKey overflow menu', () => {
       const key = 'cherrypick';
       const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
+      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      assert.equal(element.actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element.disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
 
       cleanup();
 
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+      assert.notOk(element.actionLoadingMessage);
+      assert.notInclude(element.disabledMenuActions, 'cherrypick');
     });
 
     suite('abandon change', () => {
       let alertStub: sinon.SinonStub;
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         alertStub = sinon.stub(window, 'alert');
         element.actions = {
           abandon: {
@@ -1140,43 +1209,63 @@
             enabled: true,
           },
         };
-        return element.reload();
+        await element.updateComplete;
+        // test
+        await element.reload();
       });
 
       test('abandon change with message', async () => {
         const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        await flush();
-        const abandonButton = queryAndAssert(
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = newAbandonMsg;
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(abandonButton);
+        ).click();
 
-        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+        assert.equal(
+          queryAndAssert<GrConfirmAbandonDialog>(
+            element,
+            '#confirmAbandonDialog'
+          ).message,
+          newAbandonMsg
+        );
       });
 
       test('abandon change with no message', async () => {
-        await flush();
-        const abandonButton = queryAndAssert(
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(abandonButton);
+        ).click();
 
-        assert.equal(element.$.confirmAbandonDialog.message, '');
+        assert.equal(
+          queryAndAssert<GrConfirmAbandonDialog>(
+            element,
+            '#confirmAbandonDialog'
+          ).message,
+          ''
+        );
       });
 
       test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton = queryAndAssert(
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = 'original message';
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(restoreButton);
+        ).click();
 
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = 'foo message';
+        element.handleAbandonDialogConfirm();
         assert.notOk(alertStub.called);
 
         const action = {
@@ -1202,8 +1291,8 @@
     suite('revert change', () => {
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         element.commitMessage = 'random commit message';
         element.change!.current_revision = 'abcdef' as CommitId;
         element.actions = {
@@ -1214,13 +1303,21 @@
             enabled: true,
           },
         };
-        return element.reload();
+        await element.updateComplete;
+        // test
+        await element.reload();
       });
 
       test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
-          .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
+          .stub(
+            queryAndAssert<GrConfirmRevertDialog>(
+              element,
+              '#confirmRevertDialog'
+            ),
+            'modifyRevertMsg'
+          )
           .callsFake(() => newRevertMsg);
         element.change = {
           ...createChangeViewChange(),
@@ -1244,23 +1341,29 @@
         );
         sinon
           .stub(
-            element.$.confirmRevertDialog,
-            '_populateRevertSubmissionMessage'
+            queryAndAssert<GrConfirmRevertDialog>(
+              element,
+              '#confirmRevertDialog'
+            ),
+            'populateRevertSubmissionMessage'
           )
           .callsFake(() => 'original msg');
-        await flush();
-        const revertButton = queryAndAssert(
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="revert"]'
+        ).click();
+        await element.updateComplete;
+        assert.equal(
+          queryAndAssert<GrConfirmRevertDialog>(element, '#confirmRevertDialog')
+            .message,
+          newRevertMsg
         );
-        tap(revertButton);
-        await flush();
-        assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
       });
 
       suite('revert change submitted together', () => {
         let getChangesStub: sinon.SinonStub;
-        setup(() => {
+        setup(async () => {
           element.change = {
             ...createChangeViewChange(),
             submission_id: '199 0' as ChangeSubmissionId,
@@ -1282,25 +1385,29 @@
               },
             ])
           );
+          await element.updateComplete;
         });
 
         test('confirm revert dialog shows both options', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
-          );
-          tap(revertButton);
-          await flush();
+          ).click();
+          await element.updateComplete;
           assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          const revertSingleChangeLabel = queryAndAssert(
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
+          );
+          await element.updateComplete;
+          const revertSingleChangeLabel = queryAndAssert<HTMLLabelElement>(
             confirmRevertDialog,
             '.revertSingleChange'
-          ) as HTMLLabelElement;
-          const revertSubmissionLabel = queryAndAssert(
+          );
+          const revertSubmissionLabel = queryAndAssert<HTMLLabelElement>(
             confirmRevertDialog,
             '.revertSubmission'
-          ) as HTMLLabelElement;
+          );
           assert(
             revertSingleChangeLabel.innerText.trim() === 'Revert single change'
           );
@@ -1319,48 +1426,58 @@
             '\n' +
             '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
             '\n';
-          assert.equal(confirmRevertDialog._message, expectedMsg);
-          const radioInputs = queryAll(
+          assert.equal(confirmRevertDialog.message, expectedMsg);
+          const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
             'input[name="revertOptions"]'
           );
-          tap(radioInputs[0]);
-          await flush();
+          radioInputs[0].click();
+          await element.updateComplete;
           expectedMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
             ' for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, expectedMsg);
+          assert.equal(confirmRevertDialog.message, expectedMsg);
         });
 
         test('submit fails if message is not edited', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          await flush();
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          await element.updateComplete;
+          queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
-          );
-          tap(confirmButton);
-          await flush();
-          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          ).click();
+          await element.updateComplete;
+          assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
         test('message modification is retained on switching', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          await element.updateComplete;
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
-          await flush();
-          const radioInputs = queryAll(
+          await element.updateComplete;
+          const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
             'input[name="revertOptions"]'
           );
@@ -1379,25 +1496,27 @@
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
             ' for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+          assert.equal(confirmRevertDialog.message, revertSubmissionMsg);
           const newRevertMsg = revertSubmissionMsg + 'random';
           const newSingleChangeMsg = singleChangeMsg + 'random';
-          confirmRevertDialog._message = newRevertMsg;
-          tap(radioInputs[0]);
-          await flush();
-          assert.equal(confirmRevertDialog._message, singleChangeMsg);
-          confirmRevertDialog._message = newSingleChangeMsg;
-          tap(radioInputs[1]);
-          await flush();
-          assert.equal(confirmRevertDialog._message, newRevertMsg);
-          tap(radioInputs[0]);
-          await flush();
-          assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
+          confirmRevertDialog.message = newRevertMsg;
+          await element.updateComplete;
+          radioInputs[0].click();
+          await element.updateComplete;
+          assert.equal(confirmRevertDialog.message, singleChangeMsg);
+          confirmRevertDialog.message = newSingleChangeMsg;
+          await element.updateComplete;
+          radioInputs[1].click();
+          await element.updateComplete;
+          assert.equal(confirmRevertDialog.message, newRevertMsg);
+          radioInputs[0].click();
+          await element.updateComplete;
+          assert.equal(confirmRevertDialog.message, newSingleChangeMsg);
         });
       });
 
       suite('revert single change', () => {
-        setup(() => {
+        setup(async () => {
           element.change = {
             ...createChangeViewChange(),
             submission_id: '199' as ChangeSubmissionId,
@@ -1413,35 +1532,45 @@
               },
             ])
           );
+          await element.updateComplete;
         });
 
         test('submit fails if message is not edited', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          await flush();
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          await element.updateComplete;
+          queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
-          );
-          tap(confirmButton);
-          await flush();
-          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          ).click();
+          await element.updateComplete;
+          assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
         test('confirm revert dialog shows no radio button', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          await element.updateComplete;
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          tap(revertButton);
-          await flush();
-          const confirmRevertDialog = element.$.confirmRevertDialog;
           const radioInputs = queryAll(
             confirmRevertDialog,
             'input[name="revertOptions"]'
@@ -1451,24 +1580,30 @@
             'Revert "random commit message"\n\n' +
             'This reverts commit 2000.\n\nReason ' +
             'for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, msg);
+          assert.equal(confirmRevertDialog.message, msg);
           let editedMsg = msg + 'hello';
-          confirmRevertDialog._message += 'hello';
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          confirmRevertDialog.message += 'hello';
+          const confirmButton = queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
           );
-          tap(confirmButton);
-          await flush();
+          confirmButton.click();
+          await element.updateComplete;
           // Contains generic template reason so doesn't submit
           assert.isFalse(fireActionStub.called);
-          confirmRevertDialog._message = confirmRevertDialog._message.replace(
+          confirmRevertDialog.message = confirmRevertDialog.message.replace(
             '<INSERT REASONING HERE>',
             ''
           );
           editedMsg = editedMsg.replace('<INSERT REASONING HERE>', '');
-          tap(confirmButton);
-          await flush();
+          confirmButton.click();
+          await element.updateComplete;
           assert.equal(fireActionStub.getCall(0).args[0], '/revert');
           assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
           assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
@@ -1477,7 +1612,7 @@
     });
 
     suite('mark change private', () => {
-      setup(() => {
+      setup(async () => {
         const privateAction = {
           __key: 'private',
           __type: 'change',
@@ -1497,34 +1632,41 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        return element.reload();
+        await element.updateComplete;
+        await element.reload();
       });
 
       test(
         'make sure the mark private change button is not outside of the ' +
           'overflow menu',
         async () => {
-          await flush();
+          await element.updateComplete;
           assert.isNotOk(query(element, '[data-action-key="private"]'));
         }
       );
 
       test('private change', async () => {
-        await flush();
+        await element.updateComplete;
         assert.isOk(
-          query(element.$.moreActions, 'span[data-id="private-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private-change"]'
+          )
         );
         element.setActionOverflow(ActionType.CHANGE, 'private', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="private"]'));
         assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="private-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private-change"]'
+          )
         );
       });
     });
 
     suite('unmark private change', () => {
-      setup(() => {
+      setup(async () => {
         const unmarkPrivateAction = {
           __key: 'private.delete',
           __type: 'change',
@@ -1544,28 +1686,35 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        return element.reload();
+        await element.updateComplete;
+        await element.reload();
       });
 
       test(
         'make sure the unmark private change button is not outside of the ' +
           'overflow menu',
         async () => {
-          await flush();
+          await element.updateComplete;
           assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
         }
       );
 
       test('unmark the private change', async () => {
-        await flush();
+        await element.updateComplete;
         assert.isOk(
-          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private.delete-change"]'
+          )
         );
         element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="private.delete"]'));
         assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private.delete-change"]'
+          )
         );
       });
     });
@@ -1574,8 +1723,8 @@
       let fireActionStub: sinon.SinonStub;
       let deleteAction: ActionInfo;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1589,39 +1738,36 @@
         element.actions = {
           '/': deleteAction,
         };
+        await element.updateComplete;
       });
 
       test('does not delete on action', () => {
-        element._handleDeleteTap();
+        element.handleDeleteTap();
         assert.isFalse(fireActionStub.called);
       });
 
       test('shows confirm dialog', async () => {
-        element._handleDeleteTap();
+        element.handleDeleteTap();
         assert.isFalse(
-          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+          queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
         );
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteDialog'),
-            'gr-button[primary]'
-          )
-        );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
       test('hides delete confirm on cancel', async () => {
-        element._handleDeleteTap();
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteDialog'),
-            'gr-button:not([primary])'
-          )
-        );
-        await flush();
+        element.handleDeleteTap();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteDialog'),
+          'gr-button:not([primary])'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(
-          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+          queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
         );
         assert.isFalse(fireActionStub.called);
       });
@@ -1645,7 +1791,7 @@
             foo: ['-1', ' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
       });
 
       test('added when can approve', () => {
@@ -1666,7 +1812,7 @@
 
         // Assert approve button gets removed from list of buttons.
         element.hideQuickApproveAction();
-        await flush();
+        await element.updateComplete;
         const approveButtonUpdated = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1676,8 +1822,10 @@
       });
 
       test('is first in list of secondary actions', () => {
-        const approveButton =
-          element.$.secondaryActions.querySelector('gr-button');
+        const approveButton = queryAndAssert<HTMLElement>(
+          element,
+          '#secondaryActions'
+        ).querySelector('gr-button');
         assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
       });
 
@@ -1687,7 +1835,7 @@
           status: ChangeStatus.MERGED,
         };
 
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1709,7 +1857,7 @@
             foo: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1728,7 +1876,7 @@
             bar: [],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1754,7 +1902,7 @@
             'Code-Review': ['-1', ' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1763,9 +1911,12 @@
       });
 
       test('approves when tapped', async () => {
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
-        await flush();
+        const fireActionStub = sinon.stub(element, 'fireAction');
+        queryAndAssert<GrButton>(
+          element,
+          "gr-button[data-action-key='review']"
+        ).click();
+        await element.updateComplete;
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
@@ -1785,7 +1936,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1815,7 +1966,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1841,7 +1992,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1867,7 +2018,7 @@
             bar: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1893,7 +2044,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1919,8 +2070,8 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
-        const approveButton = queryAndAssert(
+        await element.updateComplete;
+        const approveButton = queryAndAssert<GrButton>(
           element,
           "gr-button[data-action-key='review']"
         );
@@ -1952,7 +2103,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1979,7 +2130,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1992,8 +2143,8 @@
       const handler = sinon.stub();
       element.addEventListener('download-tap', handler);
       assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      await flush();
+      element.handleDownloadTap();
+      await element.updateComplete;
 
       assert.isTrue(handler.called);
     });
@@ -2006,26 +2157,26 @@
       assert.isFalse(reloadStub.called);
     });
 
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    test('toSentenceCase', () => {
+      assert.equal(element.toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element.toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element.toSentenceCase('b'), 'B');
+      assert.equal(element.toSentenceCase(''), '');
+      assert.equal(element.toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
     });
 
     suite('setActionOverflow', () => {
       test('move action from overflow', async () => {
         assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
         assert.strictEqual(
-          element.$.moreActions!.items![0].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
           'cherrypick-revision'
         );
         element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="cherrypick"]'));
         assert.notEqual(
-          element.$.moreActions!.items![0].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
           'cherrypick-revision'
         );
       });
@@ -2033,15 +2184,15 @@
       test('move action to overflow', async () => {
         assert.isOk(query(element, '[data-action-key="submit"]'));
         element.setActionOverflow(ActionType.REVISION, 'submit', true);
-        await flush();
+        await element.updateComplete;
         assert.isNotOk(query(element, '[data-action-key="submit"]'));
         assert.strictEqual(
-          element.$.moreActions.items![3].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![3].id,
           'submit-revision'
         );
       });
 
-      suite('_waitForChangeReachable', () => {
+      suite('waitForChangeReachable', () => {
         let clock: SinonFakeTimers;
         setup(() => {
           clock = sinon.useFakeTimers();
@@ -2062,13 +2213,13 @@
         const tickAndFlush = async (repetitions: number) => {
           for (let i = 1; i <= repetitions; i++) {
             clock.tick(1000);
-            await flush();
+            await element.updateComplete;
           }
         };
 
         test('succeed', async () => {
           stubRestApi('getChange').callsFake(makeGetChange(5));
-          const promise = element._waitForChangeReachable(
+          const promise = element.waitForChangeReachable(
             123 as NumericChangeId
           );
           tickAndFlush(5);
@@ -2078,7 +2229,7 @@
 
         test('fail', async () => {
           stubRestApi('getChange').callsFake(makeGetChange(6));
-          const promise = element._waitForChangeReachable(
+          const promise = element.waitForChangeReachable(
             123 as NumericChangeId
           );
           tickAndFlush(6);
@@ -2088,14 +2239,14 @@
       });
     });
 
-    suite('_send', () => {
+    suite('send', () => {
       let cleanup: sinon.SinonStub;
       const payload = {foo: 'bar'};
       let onShowError: sinon.SinonStub;
       let onShowAlert: sinon.SinonStub;
       let getResponseObjectStub: sinon.SinonStub;
 
-      setup(() => {
+      setup(async () => {
         cleanup = sinon.stub();
         element.changeNum = 42 as NumericChangeId;
         element.latestPatchNum = 12 as PatchSetNum;
@@ -2104,7 +2255,8 @@
           revisions: createRevisions(element.latestPatchNum as number),
           messages: createChangeMessages(1),
         };
-        element.change!._number = 42 as NumericChangeId;
+        element.change._number = 42 as NumericChangeId;
+        await element.updateComplete;
 
         onShowError = sinon.stub();
         element.addEventListener('show-error', onShowError);
@@ -2131,7 +2283,7 @@
         });
 
         test('change action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2153,7 +2305,7 @@
         });
 
         suite('show revert submission dialog', () => {
-          setup(() => {
+          setup(async () => {
             element.change!.submission_id = '199' as ChangeSubmissionId;
             element.change!.current_revision = '2000' as CommitId;
             stubRestApi('getChanges').returns(
@@ -2172,6 +2324,7 @@
                 },
               ])
             );
+            await element.updateComplete;
           });
         });
 
@@ -2188,7 +2341,7 @@
           });
 
           test('revert submission single change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2196,7 +2349,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2220,7 +2373,7 @@
                 ],
               })
             );
-            showActionDialogStub = sinon.stub(element, '_showActionDialog');
+            showActionDialogStub = sinon.stub(element, 'showActionDialog');
             navigateToSearchQueryStub = sinon.stub(
               GerritNav,
               'navigateToSearchQuery'
@@ -2228,7 +2381,7 @@
           });
 
           test('revert submission multiple change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2236,7 +2389,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2250,7 +2403,7 @@
         });
 
         test('revision action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2281,7 +2434,7 @@
           const sendStub = stubRestApi('executeChangeAction');
 
           return element
-            ._send(
+            .send(
               HttpMethod.DELETE,
               payload,
               '/endpoint',
@@ -2308,14 +2461,15 @@
           );
           const sendStub = stubRestApi('executeChangeAction').callsFake(
             (_num, _method, _patchNum, _endpoint, _payload, onErr) => {
+              // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
               onErr!();
               return Promise.resolve(undefined);
             }
           );
-          const handleErrorStub = sinon.stub(element, '_handleResponseError');
+          const handleErrorStub = sinon.stub(element, 'handleResponseError');
 
           return element
-            ._send(
+            .send(
               HttpMethod.DELETE,
               payload,
               '/endpoint',
@@ -2333,12 +2487,12 @@
       });
     });
 
-    test('_handleAction reports', () => {
-      sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_handleChangeAction');
+    test('handleAction reports', () => {
+      sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'handleChangeAction');
 
       const reportStub = stubReporting('reportInteraction');
-      element._handleAction(ActionType.CHANGE, 'key');
+      element.handleAction(ActionType.CHANGE, 'key');
       assert.isTrue(reportStub.called);
       assert.equal(reportStub.lastCall.args[0], 'change-key');
     });
@@ -2349,7 +2503,7 @@
 
     let changeRevisionActions: ActionNameToActionInfoMap = {};
 
-    setup(() => {
+    setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
         Promise.resolve(changeRevisionActions)
       );
@@ -2359,7 +2513,9 @@
         .stub(getPluginLoader(), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
-      element = basicFixture.instantiate();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
       // getChangeRevisionActions is not called without
       // set the following properties
       element.change = createChangeViewChange();
@@ -2367,33 +2523,23 @@
       element.latestPatchNum = 2 as PatchSetNum;
 
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
+      await element.updateComplete;
+      await element.reload();
     });
 
     test('confirmSubmitDialog and confirmRebase properties are changed', () => {
       changeRevisionActions = {};
       element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: HttpMethod.POST,
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      rebaseAction.enabled = false;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+      assert.strictEqual(
+        queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+          .action,
+        null
+      );
+      assert.strictEqual(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase')
+          .rebaseOnCurrent,
+        null
+      );
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 2305a74..9df22f1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -29,17 +29,9 @@
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-submit-requirements/gr-submit-requirements';
-import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
 import '../../shared/gr-account-list/gr-account-list';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-metadata_html';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   ChangeStatus,
@@ -47,7 +39,6 @@
   SubmitType,
 } from '../../../constants/constants';
 import {changeIsOpen, isOwner} from '../../../utils/change-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   AccountInfo,
@@ -56,7 +47,6 @@
   ChangeInfo,
   CommitId,
   CommitInfo,
-  ElementPropertyDeepChange,
   GpgKeyInfo,
   Hashtag,
   isAccount,
@@ -92,15 +82,18 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
+import {LitElement, css, html, nothing, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {changeMetadataStyles} from '../../../styles/gr-change-metadata-shared-styles';
+import {when} from 'lit/directives/when';
+import {ifDefined} from 'lit/directives/if-defined';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
-enum ChangeRole {
+export enum ChangeRole {
   OWNER = 'owner',
   UPLOADER = 'uploader',
   AUTHOR = 'author',
@@ -129,275 +122,759 @@
   message: string;
 }
 
-export interface GrChangeMetadata {
-  $: {
-    webLinks: HTMLElement;
-  };
-}
-
 @customElement('gr-change-metadata')
-export class GrChangeMetadata extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeMetadata extends LitElement {
   /**
    * Fired when the change topic is changed.
    *
    * @event topic-changed
    */
+  @query('#webLinks') webLinks?: HTMLElement;
 
-  @property({type: Object})
-  change?: ParsedChangeInfo;
+  @property({type: Object}) change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  revertedChange?: ChangeInfo;
+  @property({type: Object}) revertedChange?: ChangeInfo;
 
-  @property({type: Object, notify: true})
-  labels?: LabelNameToInfoMap;
+  @property({type: Object}) account?: AccountDetailInfo;
 
-  @property({type: Object})
-  account?: AccountDetailInfo;
+  @property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
 
-  @property({type: Object})
-  revision?: RevisionInfo | EditRevisionInfo;
+  @property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
 
-  @property({type: Object})
-  commitInfo?: CommitInfoWithRequiredCommit;
+  @property({type: Object}) serverConfig?: ServerInfo;
 
-  @property({type: Boolean, computed: '_computeIsMutable(account)'})
-  _mutable = false;
+  @property({type: Boolean}) parentIsCurrent?: boolean;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
+  // private but used in test
+  @state() mutable = false;
 
-  @property({type: Boolean})
-  parentIsCurrent?: boolean;
+  @state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
 
-  @property({type: String})
-  readonly _notCurrentMessage = NOT_CURRENT_MESSAGE;
+  // private but used in test
+  @state() topicReadOnly = true;
 
-  @property({
-    type: Boolean,
-    computed: '_computeTopicReadOnly(_mutable, change)',
-  })
-  _topicReadOnly = true;
+  // private but used in test
+  @state() hashtagReadOnly = true;
 
-  @property({
-    type: Boolean,
-    computed: '_computeHashtagReadOnly(_mutable, change)',
-  })
-  _hashtagReadOnly = true;
+  @state() private pushCertificateValidation?: PushCertificateValidationInfo;
 
-  @property({
-    type: Object,
-    computed: '_computePushCertificateValidation(serverConfig, change)',
-  })
-  _pushCertificateValidation?: PushCertificateValidationInfo;
+  // private but used in test
+  @state() settingTopic = false;
 
-  @property({type: Boolean, computed: '_computeShowRequirements(change)'})
-  _showRequirements = false;
+  // private but used in test
+  @state() currentParents: ParentCommitInfo[] = [];
 
-  @property({type: Boolean, computed: '_computeIsWip(change)'})
-  _isWip = false;
+  @state() private showAllSections = false;
 
-  @property({type: String})
-  _newHashtag?: Hashtag;
+  @state() private queryTopic?: AutocompleteQuery;
 
-  @property({type: Boolean})
-  _settingTopic = false;
+  @state() private queryHashtag?: AutocompleteQuery;
 
-  @property({type: Array, computed: '_computeParents(change, revision)'})
-  _currentParents: ParentCommitInfo[] = [];
-
-  @property({type: Object})
-  _CHANGE_ROLE = ChangeRole;
-
-  @property({type: Object})
-  _SECTION = Metadata;
-
-  @property({type: Boolean})
-  _showAllSections = false;
-
-  @property({type: Object})
-  queryTopic?: AutocompleteQuery;
-
-  restApiService = getAppContext().restApiService;
+  private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  override ready() {
-    super.ready();
-    this.queryTopic = (input: string) => this._getTopicSuggestions(input);
+  constructor() {
+    super();
+    this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+    this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
-  @observe('change.labels')
-  _labelsChanged(labels?: LabelNameToInfoMap) {
-    this.labels = {...labels};
+  static override styles = [
+    sharedStyles,
+    fontStyles,
+    changeMetadataStyles,
+    css`
+      :host {
+        display: table;
+      }
+      gr-submit-requirements {
+        --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+      }
+      gr-editable-label {
+        max-width: 9em;
+      }
+      .webLink {
+        display: block;
+      }
+      gr-account-chip[disabled],
+      gr-linked-chip[disabled] {
+        opacity: 0;
+        pointer-events: none;
+      }
+      .hashtagChip {
+        padding-bottom: var(--spacing-s);
+      }
+      /* consistent with section .title, .value */
+      .hashtagChip:not(last-of-type) {
+        padding-bottom: var(--spacing-s);
+      }
+      .hashtagChip:last-of-type {
+        display: inline;
+        vertical-align: top;
+      }
+      .parentList.merge {
+        list-style-type: decimal;
+        padding-left: var(--spacing-l);
+      }
+      .parentList gr-commit-info {
+        display: inline-block;
+      }
+      .hideDisplay,
+      #parentNotCurrentMessage {
+        display: none;
+      }
+      .icon {
+        margin: -3px 0;
+      }
+      .icon.help,
+      .icon.notTrusted {
+        color: var(--warning-foreground);
+      }
+      .icon.invalid {
+        color: var(--negative-red-text-color);
+      }
+      .icon.trusted {
+        color: var(--positive-green-text-color);
+      }
+      .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
+        --arrow-color: var(--warning-foreground);
+        display: inline-block;
+      }
+      .oldSeparatedSection {
+        margin-top: var(--spacing-l);
+        padding: var(--spacing-m) 0;
+      }
+      .separatedSection {
+        padding: var(--spacing-m) 0;
+      }
+      .hashtag gr-linked-chip,
+      .topic gr-linked-chip {
+        --linked-chip-text-color: var(--link-color);
+      }
+      gr-reviewer-list {
+        --account-max-length: 100px;
+        max-width: 285px;
+      }
+      .metadata-title {
+        color: var(--deemphasized-text-color);
+        padding-left: var(--metadata-horizontal-padding);
+      }
+      .metadata-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: flex-end;
+        /* The goal is to achieve alignment of the owner account chip and the
+         commit message box. Their top border should be on the same line. */
+        margin-bottom: var(--spacing-s);
+      }
+      .show-all-button iron-icon {
+        color: inherit;
+        --iron-icon-height: 18px;
+        --iron-icon-width: 18px;
+      }
+      gr-vote-chip {
+        --gr-vote-chip-width: 14px;
+        --gr-vote-chip-height: 14px;
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.change) return nothing;
+    return html`<div>
+      <div class="metadata-header">
+        <h3 class="metadata-title heading-3">Change Info</h3>
+        ${this.renderShowAllButton()}
+      </div>
+      ${this.renderSubmitted()} ${this.renderUpdated()} ${this.renderOwner()}
+      ${this.renderNonOwner(ChangeRole.UPLOADER)}
+      ${this.renderNonOwner(ChangeRole.AUTHOR)}
+      ${this.renderNonOwner(ChangeRole.COMMITTER)} ${this.renderReviewers()}
+      ${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
+      ${this.renderMergedAs()} ${this.renderShowReverCreatedAs()}
+      ${this.renderTopic()} ${this.renderCherryPickOf()}
+      ${this.renderStrategy()} ${this.renderHashTags()}
+      ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
+      <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param
+          name="labels"
+          .value=${{...this.change?.labels}}
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="change"
+          .value=${this.change}
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="revision"
+          .value=${this.revision}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>`;
   }
 
-  @observe('change')
-  _changeChanged(_: ParsedChangeInfo) {
-    this._settingTopic = false;
+  private renderShowAllButton() {
+    return html`<gr-button
+      link
+      class="show-all-button"
+      @click=${this.onShowAllClick}
+      >${this.showAllSections ? 'Show less' : 'Show all'}
+      <iron-icon
+        icon="gr-icons:expand-more"
+        ?hidden=${this.showAllSections}
+      ></iron-icon
+      ><iron-icon
+        icon="gr-icons:expand-less"
+        ?hidden=${!this.showAllSections}
+      ></iron-icon>
+    </gr-button>`;
   }
 
-  _computeHideStrategy(change?: ParsedChangeInfo) {
-    return !changeIsOpen(change);
+  private renderSubmitted() {
+    if (!this.change!.submitted) return nothing;
+    return html`<section class=${this.computeDisplayState(Metadata.SUBMITTED)}>
+      <span class="title">Submitted</span>
+      <span class="value">
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.change!.submitted}
+          showYesterday
+        ></gr-date-formatter>
+      </span>
+    </section> `;
+  }
+
+  private renderUpdated() {
+    return html`<section class=${this.computeDisplayState(Metadata.UPDATED)}>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip
+          title="Last update of (meta)data for this change."
+        >
+          Updated
+        </gr-tooltip-content>
+      </span>
+      <span class="value">
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.change!.updated}
+          showYesterday
+        ></gr-date-formatter>
+      </span>
+    </section>`;
+  }
+
+  private renderOwner() {
+    const change = this.change!;
+    return html`<section class=${this.computeDisplayState(Metadata.OWNER)}>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip
+          title="This user created or uploaded the first patchset of this change."
+        >
+          Owner
+        </gr-tooltip-content>
+      </span>
+      <span class="value">
+        <gr-account-chip
+          .account=${change.owner}
+          .change=${change}
+          highlightAttention
+          .vote=${this.computeVote(change.owner)}
+          .label=${this.computeCodeReviewLabel()}
+        >
+          <gr-vote-chip
+            slot="vote-chip"
+            .vote=${this.computeVote(change.owner)}
+            .label=${this.computeCodeReviewLabel()}
+            circle-shape
+          ></gr-vote-chip>
+        </gr-account-chip>
+        ${when(
+          this.pushCertificateValidation,
+          () => html`<gr-tooltip-content
+            has-tooltip
+            title=${this.pushCertificateValidation!.message}
+          >
+            <iron-icon
+              class="icon ${this.pushCertificateValidation!.class}"
+              icon=${this.pushCertificateValidation!.icon}
+            >
+            </iron-icon>
+          </gr-tooltip-content>`
+        )}
+      </span>
+    </section>`;
+  }
+
+  renderNonOwner(role: ChangeRole) {
+    if (!this.getNonOwnerRole(role)) return nothing;
+    let title = '';
+    let name = '';
+    if (role === ChangeRole.UPLOADER) {
+      title =
+        "This user uploaded the patchset to Gerrit (typically by running the 'git push' command).";
+      name = 'Uploader';
+    } else if (role === ChangeRole.AUTHOR) {
+      title = 'This user wrote the code change.';
+      name = 'Author';
+    } else if (role === ChangeRole.COMMITTER) {
+      title =
+        'This user committed the code change to the Git repository (typically to the local Git repo before uploading).';
+      name = 'Committer';
+    }
+    return html`<section>
+      <span class="title">
+        <gr-tooltip-content has-tooltip .title=${title}>
+          ${name}
+        </gr-tooltip-content>
+      </span>
+      <span class="value">
+        <gr-account-chip
+          .account=${this.getNonOwnerRole(role)}
+          .change=${this.change}
+          ?highlightAttention=${role === ChangeRole.UPLOADER}
+          .vote=${this.computeVoteForRole(role)}
+          .label=${this.computeCodeReviewLabel()}
+        >
+          <gr-vote-chip
+            slot="vote-chip"
+            .vote=${this.computeVoteForRole(role)}
+            .label=${this.computeCodeReviewLabel()}
+            circle-shape
+          ></gr-vote-chip>
+        </gr-account-chip>
+      </span>
+    </section>`;
+  }
+
+  private renderReviewers() {
+    return html`<section class=${this.computeDisplayState(Metadata.REVIEWERS)}>
+      <span class="title">Reviewers</span>
+      <span class="value">
+        <gr-reviewer-list
+          .change=${this.change}
+          ?mutable=${this.mutable}
+          reviewers-only
+          .account=${this.account}
+        ></gr-reviewer-list>
+      </span>
+    </section>`;
+  }
+
+  private renderCCs() {
+    return html`<section class=${this.computeDisplayState(Metadata.CC)}>
+      <span class="title">CC</span>
+      <span class="value">
+        <gr-reviewer-list
+          .change=${this.change}
+          ?mutable=${this.mutable}
+          ccs-only
+          .account=${this.account}
+        ></gr-reviewer-list>
+      </span>
+    </section>`;
+  }
+
+  private renderProjectBranch() {
+    const change = this.change!;
+    return when(
+      this.computeShowRepoBranchTogether(),
+      () =>
+        html`<section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
+          <span class="title">Repo | Branch</span>
+          <span class="value">
+            <a href=${this.computeProjectUrl(change.project)}
+              >${change.project}</a
+            >
+            |
+            <a href=${this.computeBranchUrl(change.project, change.branch)}
+              >${change.branch}</a
+            >
+          </span>
+        </section>`,
+
+      () => html` <section
+          class=${this.computeDisplayState(Metadata.REPO_BRANCH)}
+        >
+          <span class="title">Repo</span>
+          <span class="value">
+            <a href=${this.computeProjectUrl(change.project)}>
+              <gr-limited-text
+                limit="40"
+                .text=${change.project}
+              ></gr-limited-text>
+            </a>
+          </span>
+        </section>
+        <section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
+          <span class="title">Branch</span>
+          <span class="value">
+            <a href=${this.computeBranchUrl(change.project, change.branch)}>
+              <gr-limited-text
+                limit="40"
+                .text=${change.branch}
+              ></gr-limited-text>
+            </a>
+          </span>
+        </section>`
+    );
+  }
+
+  private renderParent() {
+    return html`<section class=${this.computeDisplayState(Metadata.PARENT)}>
+      <span class="title"
+        >${this.currentParents.length > 1 ? 'Parents' : 'Parent'}</span
+      >
+      <span class="value">
+        <ol class=${this.computeParentListClass()}>
+          ${this.currentParents.map(
+            parent => html` <li>
+              <gr-commit-info
+                .change=${this.change}
+                .commitInfo=${parent}
+                .serverConfig=${this.serverConfig}
+              ></gr-commit-info>
+              <gr-tooltip-content
+                id="parentNotCurrentMessage"
+                has-tooltip
+                show-icon
+                .title=${this.notCurrentMessage}
+              ></gr-tooltip-content>
+            </li>`
+          )}
+        </ol>
+      </span>
+    </section>`;
+  }
+
+  private renderMergedAs() {
+    const changeMerged = this.change?.status === ChangeStatus.MERGED;
+    if (!changeMerged) return nothing;
+    return html`<section class=${this.computeDisplayState(Metadata.MERGED_AS)}>
+      <span class="title">Merged As</span>
+      <span class="value">
+        <gr-commit-info
+          .change=${this.change}
+          .commitInfo=${this.computeMergedCommitInfo(
+            this.change?.current_revision,
+            this.change?.revisions
+          )}
+          .serverConfig=${this.serverConfig}
+        ></gr-commit-info>
+      </span>
+    </section>`;
+  }
+
+  private renderShowReverCreatedAs() {
+    if (!this.showRevertCreatedAs()) return nothing;
+
+    return html`<section
+      class=${this.computeDisplayState(Metadata.REVERT_CREATED_AS)}
+    >
+      <span class="title">${this.getRevertSectionTitle()}</span>
+      <span class="value">
+        <gr-commit-info
+          .change=${this.change}
+          .commitInfo=${this.computeRevertCommit()}
+          .serverConfig=${this.serverConfig}
+        ></gr-commit-info>
+      </span>
+    </section>`;
+  }
+
+  private renderTopic() {
+    const showTopic = this.change?.topic || !this.topicReadOnly;
+    if (!showTopic) return nothing;
+
+    return html`<section
+      class="topic ${this.computeDisplayState(Metadata.TOPIC, this.account)}"
+    >
+      <span class="title">Topic</span>
+      <span class="value">
+        ${when(
+          this.showTopicChip(),
+          () => html` <gr-linked-chip
+            .text=${this.change?.topic}
+            limit="40"
+            href=${GerritNav.getUrlForTopic(this.change!.topic!)}
+            ?removable=${!this.topicReadOnly}
+            @remove=${this.handleTopicRemoved}
+          ></gr-linked-chip>`
+        )}
+        ${when(
+          this.showAddTopic(),
+          () =>
+            html` <gr-editable-label
+              class="topicEditableLabel"
+              labelText="Add a topic"
+              .value=${this.change?.topic}
+              maxLength="1024"
+              .placeholder=${this.computeTopicPlaceholder()}
+              ?readOnly=${this.topicReadOnly}
+              @changed=${this.handleTopicChanged}
+              showAsEditPencil
+              autocomplete
+              .query=${this.queryTopic}
+            ></gr-editable-label>`
+        )}
+      </span>
+    </section>`;
+  }
+
+  private renderCherryPickOf() {
+    if (!this.showCherryPickOf()) return nothing;
+    return html` <section
+      class=${this.computeDisplayState(Metadata.CHERRY_PICK_OF)}
+    >
+      <span class="title">Cherry pick of</span>
+      <span class="value">
+        <a
+          href=${this.computeCherryPickOfUrl(
+            this.change?.cherry_pick_of_change,
+            this.change?.cherry_pick_of_patch_set,
+            this.change?.project
+          )}
+        >
+          <gr-limited-text
+            text="${this.change?.cherry_pick_of_change},${this.change
+              ?.cherry_pick_of_patch_set}"
+            limit="40"
+          >
+          </gr-limited-text>
+        </a>
+      </span>
+    </section>`;
+  }
+
+  private renderStrategy() {
+    if (!changeIsOpen(this.change)) return nothing;
+    return html`<section
+      class="strategy ${this.computeDisplayState(Metadata.STRATEGY)}"
+    >
+      <span class="title">Strategy</span>
+      <span class="value">${this.computeStrategy()}</span>
+    </section>`;
+  }
+
+  private renderHashTags() {
+    return html`<section
+      class="hashtag ${this.computeDisplayState(Metadata.HASHTAGS)}"
+    >
+      <span class="title">Hashtags</span>
+      <span class="value">
+        ${(this.change?.hashtags ?? []).map(
+          hashtag => html`<gr-linked-chip
+            class="hashtagChip"
+            .text=${hashtag}
+            href=${this.computeHashtagUrl(hashtag)}
+            ?removable=${!this.hashtagReadOnly}
+            @remove=${this.handleHashtagRemoved}
+            limit="40"
+          >
+          </gr-linked-chip>`
+        )}
+        ${when(
+          !this.hashtagReadOnly,
+          () => html`
+            <gr-editable-label
+              uppercase
+              labelText="Add a hashtag"
+              .placeholder=${this.computeHashtagPlaceholder()}
+              .readOnly=${this.hashtagReadOnly}
+              @changed=${this.handleHashtagChanged}
+              showAsEditPencil
+              autocomplete
+              .query=${this.queryHashtag}
+            ></gr-editable-label>
+          `
+        )}
+      </span>
+    </section>`;
+  }
+
+  private renderSubmitRequirements() {
+    return html`<div class="separatedSection">
+      <gr-submit-requirements
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+      ></gr-submit-requirements>
+    </div>`;
+  }
+
+  private renderWeblinks() {
+    const webLinks = this.computeWebLinks();
+    if (!webLinks.length) return nothing;
+    return html`<section id="webLinks">
+      <span class="title">Links</span>
+      <span class="value">
+        ${webLinks.map(
+          link => html`<a
+            href=${ifDefined(link.url)}
+            class="webLink"
+            rel="noopener"
+            target="_blank"
+          >
+            ${link.name}
+          </a>`
+        )}
+      </span>
+    </section>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('account')) {
+      this.mutable = this.computeIsMutable();
+    }
+    if (changedProperties.has('mutable') || changedProperties.has('change')) {
+      this.topicReadOnly = this.computeTopicReadOnly();
+      this.hashtagReadOnly = this.computeHashtagReadOnly();
+    }
+    if (changedProperties.has('change')) {
+      this.settingTopic = false;
+    }
+    if (
+      changedProperties.has('serverConfig') ||
+      changedProperties.has('change')
+    ) {
+      this.pushCertificateValidation = this.computePushCertificateValidation();
+    }
+    if (changedProperties.has('revision') || changedProperties.has('change')) {
+      this.currentParents = this.computeParents();
+    }
   }
 
   /**
    * @return If array is empty, returns undefined instead so
    * an existential check can be used to hide or show the webLinks
    * section.
+   * private but used in test
    */
-  _computeWebLinks(
-    commitInfo?: CommitInfoWithRequiredCommit,
-    serverConfig?: ServerInfo
-  ) {
-    if (!commitInfo) return undefined;
+  computeWebLinks() {
+    if (!this.commitInfo) return [];
     const weblinks = GerritNav.getChangeWeblinks(
       this.change ? this.change.project : ('' as RepoName),
-      commitInfo.commit,
+      this.commitInfo.commit,
       {
-        weblinks: commitInfo.web_links,
-        config: serverConfig,
+        weblinks: this.commitInfo.web_links,
+        config: this.serverConfig,
       }
     );
-    return weblinks.length ? weblinks : undefined;
+    return weblinks.length ? weblinks : [];
   }
 
-  _isChangeMerged(change?: ParsedChangeInfo) {
-    return change?.status === ChangeStatus.MERGED;
-  }
-
-  _computeStrategy(change?: ParsedChangeInfo) {
-    if (!change?.submit_type) {
+  private computeStrategy() {
+    if (!this.change?.submit_type) {
       return '';
     }
 
-    return SubmitTypeLabel.get(change.submit_type);
+    return SubmitTypeLabel.get(this.change.submit_type);
   }
 
-  _computeLabelNames(labels?: LabelNameToInfoMap) {
+  // private but used in test
+  computeLabelNames(labels?: LabelNameToInfoMap) {
     return labels ? Object.keys(labels).sort() : [];
   }
 
-  _handleTopicChanged(e: CustomEvent<string>) {
+  // private but used in test
+  handleTopicChanged(e: CustomEvent<string>) {
     if (!this.change) {
       throw new Error('change must be set');
     }
     const lastTopic = this.change.topic;
     const topic = e.detail.length ? e.detail : undefined;
-    this._settingTopic = true;
+    this.settingTopic = true;
     const topicChangedForChangeNumber = this.change._number;
+    const change = this.change;
     this.restApiService
       .setChangeTopic(topicChangedForChangeNumber, topic)
       .then(newTopic => {
         if (this.change?._number !== topicChangedForChangeNumber) return;
-        this._settingTopic = false;
-        this.set(['change', 'topic'], newTopic);
+        this.settingTopic = false;
+        if (this.change === change) {
+          this.change.topic = newTopic as TopicName;
+          this.requestUpdate();
+        }
         if (newTopic !== lastTopic) {
           fireEvent(this, 'topic-changed');
         }
       });
   }
 
-  _showAddTopic(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
-    settingTopic?: boolean,
-    topicReadOnly?: boolean
-  ) {
-    const hasTopic = !!changeRecord?.base?.topic;
-    return !hasTopic && !settingTopic && topicReadOnly === false;
+  // private but used in test
+  showAddTopic() {
+    const hasTopic = !!this.change?.topic;
+    return !hasTopic && !this.settingTopic && this.topicReadOnly === false;
   }
 
-  _showTopic(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
-    topicReadOnly?: boolean
-  ) {
-    const hasTopic = !!changeRecord?.base?.topic;
-    return hasTopic || !topicReadOnly;
+  // private but used in test
+  showTopicChip() {
+    const hasTopic = !!this.change?.topic;
+    return hasTopic && !this.settingTopic;
   }
 
-  _showTopicChip(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
-    settingTopic?: boolean
-  ) {
-    const hasTopic = !!changeRecord?.base?.topic;
-    return hasTopic && !settingTopic;
-  }
-
-  _showCherryPickOf(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
-  ) {
+  // private but used in test
+  showCherryPickOf() {
     const hasCherryPickOf =
-      !!changeRecord?.base?.cherry_pick_of_change &&
-      !!changeRecord?.base?.cherry_pick_of_patch_set;
+      !!this.change?.cherry_pick_of_change &&
+      !!this.change?.cherry_pick_of_patch_set;
     return hasCherryPickOf;
   }
 
-  _handleHashtagChanged() {
+  // private but used in test
+  handleHashtagChanged(e: CustomEvent<string>) {
     if (!this.change) {
       throw new Error('change must be set');
     }
-    if (!this._newHashtag?.length) {
+    const newHashtag = e.detail.length ? e.detail : undefined;
+    if (!newHashtag?.length) {
       return;
     }
-    const newHashtag = this._newHashtag;
-    this._newHashtag = '' as Hashtag;
+    const change = this.change;
     this.restApiService
-      .setChangeHashtag(this.change._number, {add: [newHashtag]})
+      .setChangeHashtag(this.change._number, {add: [newHashtag as Hashtag]})
       .then(newHashtag => {
-        this.set(['change', 'hashtags'], newHashtag);
-        fireEvent(this, 'hashtag-changed');
+        if (this.change === change) {
+          this.change.hashtags = newHashtag;
+          this.requestUpdate();
+          fireEvent(this, 'hashtag-changed');
+        }
       });
   }
 
-  _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return !mutable || !change?.actions?.topic?.enabled;
+  // private but used in test
+  computeTopicReadOnly() {
+    return !this.mutable || !this.change?.actions?.topic?.enabled;
   }
 
-  _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return !mutable || !change?.actions?.hashtags?.enabled;
+  // private but used in test
+  computeHashtagReadOnly() {
+    return !this.mutable || !this.change?.actions?.hashtags?.enabled;
   }
 
-  _computeTopicPlaceholder(_topicReadOnly?: boolean) {
+  private computeTopicPlaceholder() {
     // Action items in Material Design are uppercase -- placeholder label text
     // is sentence case.
-    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+    return this.topicReadOnly ? 'No topic' : 'ADD TOPIC';
   }
 
-  _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
-    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
-  }
-
-  _computeShowRequirements(change?: ParsedChangeInfo) {
-    if (!change) {
-      return false;
-    }
-    if (change.status !== ChangeStatus.NEW) {
-      // TODO(maximeg) change this to display the stored
-      // requirements, once it is implemented server-side.
-      return false;
-    }
-    const hasRequirements =
-      !!change.requirements && Object.keys(change.requirements).length > 0;
-    const hasLabels = !!change.labels && Object.keys(change.labels).length > 0;
-    return hasRequirements || hasLabels || !!change.work_in_progress;
+  private computeHashtagPlaceholder() {
+    return this.hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
   }
 
   /**
+   * private but used in test
+   *
    * @return object representing data for the push validation.
    */
-  _computePushCertificateValidation(
-    serverConfig?: ServerInfo,
-    change?: ParsedChangeInfo
-  ): PushCertificateValidationInfo | undefined {
-    if (!change || !serverConfig?.receive?.enable_signed_push) return undefined;
+  computePushCertificateValidation():
+    | PushCertificateValidationInfo
+    | undefined {
+    if (!this.change || !this.serverConfig?.receive?.enable_signed_push)
+      return undefined;
 
-    const rev = change.revisions[change.current_revision];
+    const rev = this.change.revisions[this.change.current_revision];
     if (!rev.push_certificate?.key) {
       return {
         class: 'help',
@@ -412,13 +889,13 @@
         return {
           class: 'invalid',
           icon: 'gr-icons:close',
-          message: this._problems('Push certificate is invalid', key),
+          message: this.problems('Push certificate is invalid', key),
         };
       case GpgKeyInfoStatus.OK:
         return {
           class: 'notTrusted',
           icon: 'gr-icons:info',
-          message: this._problems(
+          message: this.problems(
             'Push certificate is valid, but key is not trusted',
             key
           ),
@@ -427,7 +904,7 @@
         return {
           class: 'trusted',
           icon: 'gr-icons:check',
-          message: this._problems(
+          message: this.problems(
             'Push certificate is valid and key is trusted',
             key
           ),
@@ -440,7 +917,7 @@
     }
   }
 
-  _problems(msg: string, key: GpgKeyInfo) {
+  private problems(msg: string, key: GpgKeyInfo) {
     if (!key?.problems || key.problems.length === 0) {
       return msg;
     }
@@ -448,16 +925,17 @@
     return [msg + ':'].concat(key.problems).join('\n');
   }
 
-  _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
-    return !!repo && !!branch && repo.length + branch.length < 40;
+  private computeShowRepoBranchTogether() {
+    const {project, branch} = this.change!;
+    return !!project && !!branch && project.length + branch.length < 40;
   }
 
-  _computeProjectUrl(project?: RepoName) {
+  private computeProjectUrl(project?: RepoName) {
     if (!project) return '';
     return GerritNav.getUrlForProjectChanges(project);
   }
 
-  _computeBranchUrl(project?: RepoName, branch?: BranchName) {
+  private computeBranchUrl(project?: RepoName, branch?: BranchName) {
     if (!project || !branch || !this.change || !this.change.status) return '';
     return GerritNav.getUrlForBranch(
       branch,
@@ -468,7 +946,7 @@
     );
   }
 
-  _computeCherryPickOfUrl(
+  private computeCherryPickOfUrl(
     change?: NumericChangeId,
     patchset?: PatchSetNum,
     project?: RepoName
@@ -479,70 +957,62 @@
     return GerritNav.getUrlForChangeById(change, project, patchset);
   }
 
-  _computeTopicUrl(topic: TopicName) {
-    return GerritNav.getUrlForTopic(topic);
-  }
-
-  _computeHashtagUrl(hashtag: Hashtag) {
+  private computeHashtagUrl(hashtag: Hashtag) {
     return GerritNav.getUrlForHashtag(hashtag);
   }
 
-  _handleTopicRemoved(e: CustomEvent) {
+  private handleTopicRemoved(e: CustomEvent) {
     if (!this.change) {
       throw new Error('change must be set');
     }
     const target = e.composedPath()[0] as GrLinkedChip;
     target.disabled = true;
+    const change = this.change;
     this.restApiService
       .setChangeTopic(this.change._number)
       .then(() => {
         target.disabled = false;
-        this.set(['change', 'topic'], '');
-        fireEvent(this, 'topic-changed');
+        if (this.change === change) {
+          this.change.topic = '' as TopicName;
+          this.requestUpdate();
+          fireEvent(this, 'topic-changed');
+        }
       })
       .catch(() => {
         target.disabled = false;
       });
   }
 
-  _handleHashtagRemoved(e: CustomEvent) {
+  // private but used in test
+  handleHashtagRemoved(e: CustomEvent) {
     e.preventDefault();
     if (!this.change) {
       throw new Error('change must be set');
     }
-    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    const target = e.target as GrLinkedChip;
     target.disabled = true;
+    const change = this.change;
     this.restApiService
-      .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
+      .setChangeHashtag(change._number, {remove: [target.text as Hashtag]})
       .then(newHashtags => {
         target.disabled = false;
-        this.set(['change', 'hashtags'], newHashtags);
+        if (this.change === change) {
+          this.change.hashtags = newHashtags;
+          this.requestUpdate();
+        }
       })
       .catch(() => {
         target.disabled = false;
       });
   }
 
-  _computeIsWip(change?: ParsedChangeInfo) {
-    return !!change?.work_in_progress;
-  }
-
-  _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
-    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
-  }
-
-  _computeDisplayState(
-    showAllSections: boolean,
-    change: ParsedChangeInfo | undefined,
-    section: Metadata,
-    account?: AccountDetailInfo
-  ) {
+  private computeDisplayState(section: Metadata, account?: AccountDetailInfo) {
     // special case for Topic - show always for owners, others when set
     if (section === Metadata.TOPIC) {
       if (
-        showAllSections ||
-        isOwner(change, account) ||
-        isSectionSet(section, change)
+        this.showAllSections ||
+        isOwner(this.change, account) ||
+        isSectionSet(section, this.change)
       ) {
         return '';
       } else {
@@ -550,89 +1020,88 @@
       }
     }
     if (
-      showAllSections ||
+      this.showAllSections ||
       DisplayRules.ALWAYS_SHOW.includes(section) ||
       (DisplayRules.SHOW_IF_SET.includes(section) &&
-        isSectionSet(section, change))
+        isSectionSet(section, this.change))
     ) {
       return '';
     }
     return 'hideDisplay';
   }
 
-  _computeMergedCommitInfo(
-    current_revision: CommitId,
-    revisions: {[revisionId: string]: RevisionInfo}
-  ) {
-    const rev = revisions[current_revision];
-    if (!rev || !rev.commit) {
-      return {};
-    }
+  // private but used in test
+  computeMergedCommitInfo(
+    currentrevision?: CommitId,
+    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
+  ): CommitInfo | undefined {
+    if (!currentrevision || !revisions) return;
+    const rev = revisions[currentrevision];
+    if (!rev || !rev.commit) return;
     // CommitInfo.commit is optional. Set commit in all cases to avoid error
     // in <gr-commit-info>. @see Issue 5337
     if (!rev.commit.commit) {
-      rev.commit.commit = current_revision;
+      rev.commit.commit = currentrevision;
     }
     return rev.commit;
   }
 
-  _getRevertSectionTitle(
-    _change?: ParsedChangeInfo,
-    revertedChange?: ChangeInfo
-  ) {
-    return revertedChange?.status === ChangeStatus.MERGED
+  private getRevertSectionTitle() {
+    return this.revertedChange?.status === ChangeStatus.MERGED
       ? 'Revert Submitted As'
       : 'Revert Created As';
   }
 
-  _showRevertCreatedAs(change?: ParsedChangeInfo) {
-    if (!change?.messages) return false;
-    return getRevertCreatedChangeIds(change.messages).length > 0;
+  // private but used in test
+  showRevertCreatedAs() {
+    if (!this.change?.messages) return false;
+    return getRevertCreatedChangeIds(this.change.messages).length > 0;
   }
 
-  _computeRevertCommit(change?: ParsedChangeInfo, revertedChange?: ChangeInfo) {
+  // private but used in test
+  computeRevertCommit(): CommitInfo | undefined {
+    const {revertedChange, change} = this;
     if (revertedChange?.current_revision && revertedChange?.revisions) {
+      // TODO(TS): Fix typing
       return {
-        commit: this._computeMergedCommitInfo(
+        commit: this.computeMergedCommitInfo(
           revertedChange.current_revision,
           revertedChange.revisions
         ),
-      };
+      } as CommitInfo;
     }
     if (!change?.messages) return undefined;
-    return {commit: getRevertCreatedChangeIds(change.messages)?.[0]};
+    // TODO(TS): Fix typing
+    return {
+      commit: getRevertCreatedChangeIds(change.messages)?.[0],
+    } as unknown as CommitInfo;
   }
 
-  _computeShowAllLabelText(showAllSections: boolean) {
-    if (showAllSections) {
-      return 'Show less';
-    } else {
-      return 'Show all';
-    }
-  }
-
-  _onShowAllClick() {
-    this._showAllSections = !this._showAllSections;
+  // private but used in test
+  onShowAllClick() {
+    this.showAllSections = !this.showAllSections;
     this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'metadata',
-      toState: this._showAllSections ? 'Show all' : 'Show less',
+      toState: this.showAllSections ? 'Show all' : 'Show less',
     });
   }
 
   /**
    * Get the user with the specified role on the change. Returns undefined if the
    * user with that role is the same as the owner.
+   * private but used in test
    */
-  _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
-    if (!change?.revisions?.[change.current_revision]) return undefined;
+  getNonOwnerRole(role: ChangeRole) {
+    if (!this.change?.revisions?.[this.change.current_revision])
+      return undefined;
 
-    const rev = change.revisions[change.current_revision];
+    const rev = this.change.revisions[this.change.current_revision];
     if (!rev) return undefined;
 
     if (
       role === ChangeRole.UPLOADER &&
       rev.uploader &&
-      change.owner._account_id !== rev.uploader._account_id
+      this.change.owner._account_id !== rev.uploader._account_id
     ) {
       return rev.uploader;
     }
@@ -640,7 +1109,7 @@
     if (
       role === ChangeRole.AUTHOR &&
       rev.commit?.author &&
-      change.owner.email !== rev.commit.author.email
+      this.change.owner.email !== rev.commit.author.email
     ) {
       return rev.commit.author;
     }
@@ -648,7 +1117,7 @@
     if (
       role === ChangeRole.COMMITTER &&
       rev.commit?.committer &&
-      change.owner.email !== rev.commit.committer.email &&
+      this.change.owner.email !== rev.commit.committer.email &&
       !(
         rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
       )
@@ -659,48 +1128,32 @@
     return undefined;
   }
 
-  _computeParents(
-    change?: ParsedChangeInfo,
-    revision?: RevisionInfo | EditRevisionInfo
-  ): ParentCommitInfo[] {
-    if (!revision || !revision.commit) {
-      if (!change || !change.current_revision) {
-        return [];
-      }
-      revision = change.revisions[change.current_revision];
-      if (!revision || !revision.commit) {
-        return [];
-      }
+  // private but used in test
+  computeParents(): ParentCommitInfo[] {
+    const {change, revision} = this;
+    if (!revision?.commit) {
+      if (!change?.current_revision) return [];
+      const newRevision = change.revisions[change.current_revision];
+      return newRevision?.commit?.parents ?? [];
     }
-    return revision.commit.parents;
+    return revision?.commit?.parents ?? [];
   }
 
-  _computeParentsLabel(parents?: ParentCommitInfo[]) {
-    return parents && parents.length > 1 ? 'Parents' : 'Parent';
-  }
-
-  _computeParentListClass(
-    parents?: ParentCommitInfo[],
-    parentIsCurrent?: boolean
-  ) {
-    // Undefined check for polymer 2
-    if (parents === undefined || parentIsCurrent === undefined) {
-      return '';
-    }
-
+  // private but used in test
+  computeParentListClass() {
     return [
       'parentList',
-      parents && parents.length > 1 ? 'merge' : 'nonMerge',
-      parentIsCurrent ? 'current' : 'notCurrent',
+      this.currentParents.length > 1 ? 'merge' : 'nonMerge',
+      this.parentIsCurrent ? 'current' : 'notCurrent',
     ].join(' ');
   }
 
-  _computeIsMutable(account?: AccountDetailInfo) {
-    return account && !!Object.keys(account).length;
+  private computeIsMutable() {
+    return !!this.account && !!Object.keys(this.account).length;
   }
 
   editTopic() {
-    if (this._topicReadOnly || !this.change || this.change.topic) {
+    if (this.topicReadOnly || !this.change || this.change.topic) {
       return;
     }
     // Cannot use `this.$.ID` syntax because the element exists inside of a
@@ -710,20 +1163,9 @@
     ).open();
   }
 
-  _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
-    if (!change) {
-      return undefined;
-    }
-    const provider = GrReviewerSuggestionsProvider.create(
-      this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
-    );
-    provider.init();
-    return provider;
-  }
-
-  _getTopicSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  private getTopicSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
       .getChangesWithSimilarTopic(input)
       .then(response =>
@@ -737,31 +1179,40 @@
       );
   }
 
-  _showNewSubmitRequirements(change?: ParsedChangeInfo) {
-    return showNewSubmitRequirements(this.flagsService, change);
+  private getHashtagSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarHashtag(input)
+      .then(response =>
+        (response ?? [])
+          .flatMap(change => change.hashtags ?? [])
+          .filter(notUndefined)
+          .filter(unique)
+          .map(hashtag => {
+            return {name: hashtag, value: hashtag};
+          })
+      );
   }
 
-  _computeVoteForRole(role?: ChangeRole, change?: ParsedChangeInfo) {
-    const reviewer = this._getNonOwnerRole(change, role);
+  private computeVoteForRole(role: ChangeRole) {
+    const reviewer = this.getNonOwnerRole(role);
     if (reviewer && isAccount(reviewer)) {
-      return this._computeVote(reviewer, change);
+      return this.computeVote(reviewer);
     } else {
       return;
     }
   }
 
-  _computeVote(
-    reviewer: AccountInfo,
-    change?: ParsedChangeInfo
-  ): ApprovalInfo | undefined {
-    const codeReviewLabel = this._computeCodeReviewLabel(change);
+  private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
+    const codeReviewLabel = this.computeCodeReviewLabel();
     if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
     return getApprovalInfo(codeReviewLabel, reviewer);
   }
 
-  _computeCodeReviewLabel(change?: ParsedChangeInfo): LabelInfo | undefined {
-    if (!change || !change.labels) return;
-    return getCodeReviewLabel(change.labels);
+  private computeCodeReviewLabel(): LabelInfo | undefined {
+    if (!this.change?.labels) return;
+    return getCodeReviewLabel(this.change.labels);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
deleted file mode 100644
index d5bd585..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ /dev/null
@@ -1,559 +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-change-metadata-shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-    }
-    gr-change-requirements,
-    gr-submit-requirements {
-      --requirements-horizontal-padding: var(--metadata-horizontal-padding);
-    }
-    gr-editable-label {
-      max-width: 9em;
-    }
-    .webLink {
-      display: block;
-    }
-    gr-account-chip[disabled],
-    gr-linked-chip[disabled] {
-      opacity: 0;
-      pointer-events: none;
-    }
-    .hashtagChip {
-      padding-bottom: var(--spacing-s);
-    }
-    /* consistent with section .title, .value */
-    .hashtagChip:not(last-of-type) {
-      padding-bottom: var(--spacing-s);
-    }
-    .hashtagChip:last-of-type {
-      display: inline;
-      vertical-align: top;
-    }
-    .parentList.merge {
-      list-style-type: decimal;
-      padding-left: var(--spacing-l);
-    }
-    .parentList gr-commit-info {
-      display: inline-block;
-    }
-    .hideDisplay,
-    #parentNotCurrentMessage {
-      display: none;
-    }
-    .icon {
-      margin: -3px 0;
-    }
-    .icon.help,
-    .icon.notTrusted {
-      color: var(--warning-foreground);
-    }
-    .icon.invalid {
-      color: var(--negative-red-text-color);
-    }
-    .icon.trusted {
-      color: var(--positive-green-text-color);
-    }
-    .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-      --arrow-color: var(--warning-foreground);
-      display: inline-block;
-    }
-    .oldSeparatedSection {
-      margin-top: var(--spacing-l);
-      padding: var(--spacing-m) 0;
-    }
-    .separatedSection {
-      padding: var(--spacing-m) 0;
-    }
-    .hashtag gr-linked-chip,
-    .topic gr-linked-chip {
-      --linked-chip-text-color: var(--link-color);
-    }
-    gr-reviewer-list {
-      --account-max-length: 100px;
-      max-width: 285px;
-    }
-    .metadata-title {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-    .metadata-header {
-      display: flex;
-      justify-content: space-between;
-      align-items: flex-end;
-      /* The goal is to achieve alignment of the owner account chip and the
-         commit message box. Their top border should be on the same line. */
-      margin-bottom: var(--spacing-s);
-    }
-    .show-all-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-  </style>
-  <div>
-    <div class="metadata-header">
-      <h3 class="metadata-title heading-3">Change Info</h3>
-      <gr-button link="" class="show-all-button" on-click="_onShowAllClick"
-        >[[_computeShowAllLabelText(_showAllSections)]]
-        <iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[_showAllSections]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[!_showAllSections]]"
-        ></iron-icon>
-      </gr-button>
-    </div>
-    <template is="dom-if" if="[[change.submitted]]">
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.SUBMITTED)]]"
-      >
-        <span class="title">Submitted</span>
-        <span class="value">
-          <gr-date-formatter
-            withTooltip
-            date-str="[[change.submitted]]"
-            showYesterday=""
-          ></gr-date-formatter>
-        </span>
-      </section>
-    </template>
-    <section
-      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.UPDATED)]]"
-    >
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="Last update of (meta)data for this change."
-        >
-          Updated
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-date-formatter
-          withTooltip
-          date-str="[[change.updated]]"
-          showYesterday
-        ></gr-date-formatter>
-      </span>
-    </section>
-    <section
-      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
-    >
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user created or uploaded the first patchset of this change."
-        >
-          Owner
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[change.owner]]"
-          change="[[change]]"
-          highlightAttention
-          vote="[[_computeVote(change.owner, change)]]"
-          label="[[_computeCodeReviewLabel(change)]]"
-        >
-          <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
-            <gr-vote-chip
-              slot="vote-chip"
-              vote="[[_computeVote(change.owner, change)]]"
-              label="[[_computeCodeReviewLabel(change)]]"
-              circle-shape
-            ></gr-vote-chip>
-          </template>
-        </gr-account-chip>
-        <template is="dom-if" if="[[_pushCertificateValidation]]">
-          <gr-tooltip-content
-            has-tooltip=""
-            title$="[[_pushCertificateValidation.message]]"
-          >
-            <iron-icon
-              class$="icon [[_pushCertificateValidation.class]]"
-              icon="[[_pushCertificateValidation.icon]]"
-            >
-            </iron-icon>
-          </gr-tooltip-content>
-        </template>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user uploaded the patchset to Gerrit (typically by running the 'git push' command)."
-        >
-          Uploader
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
-          change="[[change]]"
-          highlightAttention
-          vote="[[_computeVoteForRole(_CHANGE_ROLE.UPLOADER, change)]]"
-          label="[[_computeCodeReviewLabel(change)]]"
-        >
-          <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
-            <gr-vote-chip
-              slot="vote-chip"
-              vote="[[_computeVoteForRole(_CHANGE_ROLE.UPLOADER, change)]]"
-              label="[[_computeCodeReviewLabel(change)]]"
-              circle-shape
-            ></gr-vote-chip>
-          </template>
-        </gr-account-chip>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user wrote the code change."
-        >
-          Author
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
-          change="[[change]]"
-          vote="[[_computeVoteForRole(_CHANGE_ROLE.AUTHOR, change)]]"
-          label="[[_computeCodeReviewLabel(change)]]"
-        >
-          <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
-            <gr-vote-chip
-              slot="vote-chip"
-              vote="[[_computeVoteForRole(_CHANGE_ROLE.AUTHOR, change)]]"
-              label="[[_computeCodeReviewLabel(change)]]"
-              circle-shape
-            ></gr-vote-chip>
-          </template>
-        </gr-account-chip>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user committed the code change to the Git repository (typically to the local Git repo before uploading)."
-        >
-          Committer
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
-          change="[[change]]"
-          vote="[[_computeVoteForRole(_CHANGE_ROLE.COMMITTER, change)]]"
-          label="[[_computeCodeReviewLabel(change)]]"
-        >
-          <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
-            <gr-vote-chip
-              slot="vote-chip"
-              vote="[[_computeVoteForRole(_CHANGE_ROLE.COMMITTER, change)]]"
-              label="[[_computeCodeReviewLabel(change)]]"
-              circle-shape
-            ></gr-vote-chip>
-          </template>
-        </gr-account-chip>
-      </span>
-    </section>
-    <section
-      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVIEWERS)]]"
-    >
-      <span class="title">Reviewers</span>
-      <span class="value">
-        <gr-reviewer-list
-          change="{{change}}"
-          mutable="[[_mutable]]"
-          reviewers-only=""
-          account="[[account]]"
-          server-config="[[serverConfig]]"
-        ></gr-reviewer-list>
-      </span>
-    </section>
-    <section
-      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CC)]]"
-    >
-      <span class="title">CC</span>
-      <span class="value">
-        <gr-reviewer-list
-          change="{{change}}"
-          mutable="[[_mutable]]"
-          ccs-only=""
-          account="[[account]]"
-          server-config="[[serverConfig]]"
-        ></gr-reviewer-list>
-      </span>
-    </section>
-    <template
-      is="dom-if"
-      if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
-    >
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
-      >
-        <span class="title">Repo | Branch</span>
-        <span class="value">
-          <a href$="[[_computeProjectUrl(change.project)]]"
-            >[[change.project]]</a
-          >
-          |
-          <a href$="[[_computeBranchUrl(change.project, change.branch)]]"
-            >[[change.branch]]</a
-          >
-        </span>
-      </section>
-    </template>
-    <template
-      is="dom-if"
-      if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
-    >
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
-      >
-        <span class="title">Repo</span>
-        <span class="value">
-          <a href$="[[_computeProjectUrl(change.project)]]">
-            <gr-limited-text
-              limit="40"
-              text="[[change.project]]"
-            ></gr-limited-text>
-          </a>
-        </span>
-      </section>
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
-      >
-        <span class="title">Branch</span>
-        <span class="value">
-          <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
-            <gr-limited-text
-              limit="40"
-              text="[[change.branch]]"
-            ></gr-limited-text>
-          </a>
-        </span>
-      </section>
-    </template>
-    <section
-      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.PARENT)]]"
-    >
-      <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
-      <span class="value">
-        <ol
-          class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]"
-        >
-          <template is="dom-repeat" items="[[_currentParents]]" as="parent">
-            <li>
-              <gr-commit-info
-                change="[[change]]"
-                commit-info="[[parent]]"
-                server-config="[[serverConfig]]"
-              ></gr-commit-info>
-              <gr-tooltip-content
-                id="parentNotCurrentMessage"
-                has-tooltip
-                show-icon
-                title$="[[_notCurrentMessage]]"
-              ></gr-tooltip-content>
-            </li>
-          </template>
-        </ol>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_isChangeMerged(change)]]">
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.MERGED_AS)]]"
-      >
-        <span class="title">Merged As</span>
-        <span class="value">
-          <gr-commit-info
-            change="[[change]]"
-            commit-info="[[_computeMergedCommitInfo(change.current_revision, change.revisions)]]"
-            server-config="[[serverConfig]]"
-          ></gr-commit-info>
-        </span>
-      </section>
-    </template>
-    <template is="dom-if" if="[[_showRevertCreatedAs(change)]]">
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVERT_CREATED_AS)]]"
-      >
-        <span class="title"
-          >[[_getRevertSectionTitle(change, revertedChange)]]</span
-        >
-        <span class="value">
-          <gr-commit-info
-            change="[[change]]"
-            commit-info="[[_computeRevertCommit(change, revertedChange)]]"
-            server-config="[[serverConfig]]"
-          ></gr-commit-info>
-        </span>
-      </section>
-    </template>
-    <template is="dom-if" if="[[_showTopic(change.*, _topicReadOnly)]]">
-      <section
-        class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC, account)]]"
-      >
-        <span class="title">Topic</span>
-        <span class="value">
-          <template
-            is="dom-if"
-            if="[[_showTopicChip(change.*, _settingTopic)]]"
-          >
-            <gr-linked-chip
-              text="[[change.topic]]"
-              limit="40"
-              href="[[_computeTopicUrl(change.topic)]]"
-              removable="[[!_topicReadOnly]]"
-              on-remove="_handleTopicRemoved"
-            ></gr-linked-chip>
-          </template>
-          <template
-            is="dom-if"
-            if="[[_showAddTopic(change.*, _settingTopic, _topicReadOnly)]]"
-          >
-            <gr-editable-label
-              class="topicEditableLabel"
-              label-text="Add a topic"
-              value="[[change.topic]]"
-              max-length="1024"
-              placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-              read-only="[[_topicReadOnly]]"
-              on-changed="_handleTopicChanged"
-              show-as-edit-pencil="true"
-              autocomplete="true"
-              query="[[queryTopic]]"
-            ></gr-editable-label>
-          </template>
-        </span>
-      </section>
-    </template>
-    <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CHERRY_PICK_OF)]]"
-      >
-        <span class="title">Cherry pick of</span>
-        <span class="value">
-          <a
-            href$="[[_computeCherryPickOfUrl(change.cherry_pick_of_change, change.cherry_pick_of_patch_set, change.project)]]"
-          >
-            <gr-limited-text
-              text="[[change.cherry_pick_of_change]],[[change.cherry_pick_of_patch_set]]"
-              limit="40"
-            >
-            </gr-limited-text>
-          </a>
-        </span>
-      </section>
-    </template>
-    <section
-      class$="strategy [[_computeDisplayState(_showAllSections, change, _SECTION.STRATEGY)]]"
-      hidden$="[[_computeHideStrategy(change)]]"
-    >
-      <span class="title">Strategy</span>
-      <span class="value">[[_computeStrategy(change)]]</span>
-    </section>
-    <section
-      class$="hashtag [[_computeDisplayState(_showAllSections, change, _SECTION.HASHTAGS)]]"
-    >
-      <span class="title">Hashtags</span>
-      <span class="value">
-        <template is="dom-repeat" items="[[change.hashtags]]">
-          <gr-linked-chip
-            class="hashtagChip"
-            text="[[item]]"
-            href="[[_computeHashtagUrl(item)]]"
-            removable="[[!_hashtagReadOnly]]"
-            on-remove="_handleHashtagRemoved"
-            limit="40"
-          >
-          </gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[!_hashtagReadOnly]]">
-          <gr-editable-label
-            uppercase=""
-            label-text="Add a hashtag"
-            value="{{_newHashtag}}"
-            placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
-            read-only="[[_hashtagReadOnly]]"
-            on-changed="_handleHashtagChanged"
-            show-as-edit-pencil="true"
-          ></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
-      <div class="separatedSection">
-        <gr-submit-requirements
-          change="[[change]]"
-          account="[[account]]"
-          mutable="[[_mutable]]"
-        ></gr-submit-requirements>
-      </div>
-    </template>
-    <template is="dom-if" if="[[!_showNewSubmitRequirements(change)]]">
-      <div class="oldSeparatedSection">
-        <gr-change-requirements
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[_mutable]]"
-        ></gr-change-requirements>
-      </div>
-    </template>
-    <section
-      id="webLinks"
-      hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]"
-    >
-      <span class="title">Links</span>
-      <span class="value">
-        <template
-          is="dom-repeat"
-          items="[[_computeWebLinks(commitInfo, serverConfig)]]"
-          as="link"
-        >
-          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
-            [[link.name]]
-          </a>
-        </template>
-      </span>
-    </section>
-    <gr-endpoint-decorator name="change-metadata-item">
-      <gr-endpoint-param name="labels" value="[[labels]]"></gr-endpoint-param>
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param
-        name="revision"
-        value="[[revision]]"
-      ></gr-endpoint-param>
-    </gr-endpoint-decorator>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 04c886a..a383cb1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -16,17 +16,15 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import '../../core/gr-router/gr-router';
 import './gr-change-metadata';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {GrChangeMetadata} from './gr-change-metadata';
+import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
   createUserConfig,
   createParsedChange,
   createAccountWithId,
-  createRequirement,
   createCommitInfoWithRequiredCommit,
   createWebLinkInfo,
   createGerritInfo,
@@ -38,7 +36,6 @@
 import {
   ChangeStatus,
   SubmitType,
-  RequirementStatus,
   GpgKeyInfoStatus,
 } from '../../../constants/constants';
 import {
@@ -49,217 +46,246 @@
   RevisionInfo,
   ParentCommitInfo,
   TopicName,
-  ElementPropertyDeepChange,
   PatchSetNum,
   NumericChangeId,
   LabelValueToDescriptionMap,
   Hashtag,
+  CommitInfo,
 } from '../../../types/common';
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {PluginApi} from '../../../api/plugin';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  queryAndAssert,
+  resetPlugins,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrRouter} from '../../core/gr-router/gr-router';
+import {nothing} from 'lit';
 
 const basicFixture = fixtureFromElement('gr-change-metadata');
 
 suite('gr-change-metadata tests', () => {
   let element: GrChangeMetadata;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getConfig').returns(
       Promise.resolve({
         ...createServerInfo(),
         user: {
           ...createUserConfig(),
-          anonymous_coward_name: 'test coward name',
+          anonymouscowardname: 'test coward name',
         },
       })
     );
     element = basicFixture.instantiate();
+    element.change = createParsedChange();
+    await element.updateComplete;
   });
 
-  test('_computeMergedCommitInfo', () => {
+  test('renders', async () => {
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `<div>
+      <div class="metadata-header">
+        <h3 class="heading-3 metadata-title">Change Info</h3>
+        <gr-button
+          class="show-all-button"
+          link=""
+          role="button"
+          tabindex="0"
+          aria-disabled="false"
+        >
+          Show all <iron-icon icon="gr-icons:expand-more"> </iron-icon>
+          <iron-icon hidden="" icon="gr-icons:expand-less"> </iron-icon>
+        </gr-button>
+      </div>
+      <section class="hideDisplay">
+        <span class="title">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="Last update of (meta)data for this change."
+          >
+            Updated
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-date-formatter showyesterday="" withtooltip="">
+          </gr-date-formatter>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="This user created or uploaded the first patchset of this change."
+          >
+            Owner
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip highlightattention=""
+            ><gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip
+          ></gr-account-chip>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="This user wrote the code change."
+          >
+            Author
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip><gr-vote-chip circle-shape="" slot="vote-chip"></gr-vote-chip
+          ></gr-account-chip>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="This user committed the code change to the Git repository (typically to the local Git repo before uploading)."
+          >
+            Committer
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip><gr-vote-chip circle-shape="" slot="vote-chip"></gr-account-chip>
+        </span>
+      </section>
+      <section>
+        <span class="title"> Reviewers </span>
+        <span class="value">
+          <gr-reviewer-list reviewers-only=""> </gr-reviewer-list>
+        </span>
+      </section>
+      <section class="hideDisplay">
+        <span class="title"> CC </span>
+        <span class="value">
+          <gr-reviewer-list ccs-only=""> </gr-reviewer-list>
+        </span>
+      </section>
+      <section>
+          <span class="title">
+            Repo | Branch
+          </span>
+          <span class="value">
+            <a href="">
+              test-project
+            </a>
+            |
+            <a href="">
+              test-branch
+            </a>
+          </span>
+        </section>
+      <section class="hideDisplay">
+        <span class="title">Parent</span>
+        <span class="value">
+          <ol  class="nonMerge notCurrent parentList"></ol>
+        </span>
+      </section>
+      <section class="hideDisplay strategy">
+        <span class="title"> Strategy </span> <span class="value"> </span>
+      </section>
+      <section class="hashtag hideDisplay">
+        <span class="title"> Hashtags </span>
+        <span class="value"> </span>
+      </section>
+      <div class="separatedSection">
+      <gr-submit-requirements></gr-submit-requirements>
+      </div>
+      <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param name="labels"> </gr-endpoint-param>
+        <gr-endpoint-param name="change"> </gr-endpoint-param>
+        <gr-endpoint-param name="revision"> </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>`);
+  });
+
+  test('computeMergedCommitInfo', () => {
     const dummyRevs: {[revisionId: string]: RevisionInfo} = {
       1: createRevision(1),
       2: createRevision(2),
     };
     assert.deepEqual(
-      element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
-      {}
+      element.computeMergedCommitInfo('0' as CommitId, dummyRevs),
+      undefined
     );
     assert.deepEqual(
-      element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
+      element.computeMergedCommitInfo('1' as CommitId, dummyRevs),
       dummyRevs[1].commit
     );
 
     // Regression test for issue 5337.
-    const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs);
-    assert.notDeepEqual(commit, dummyRevs[2]);
+    const commit = element.computeMergedCommitInfo('2' as CommitId, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2] as unknown as CommitInfo);
     assert.deepEqual(commit, dummyRevs[2].commit);
   });
 
-  test('computed fields', () => {
-    assert.isFalse(
-      element._computeHideStrategy({
-        ...createParsedChange(),
-        status: ChangeStatus.NEW,
-      })
-    );
-    assert.isTrue(
-      element._computeHideStrategy({
-        ...createParsedChange(),
-        status: ChangeStatus.MERGED,
-      })
-    );
-    assert.isTrue(
-      element._computeHideStrategy({
-        ...createParsedChange(),
-        status: ChangeStatus.ABANDONED,
-      })
-    );
-    assert.equal(
-      element._computeStrategy({
-        ...createParsedChange(),
-        submit_type: SubmitType.CHERRY_PICK,
-      }),
-      'Cherry Pick'
-    );
-    assert.equal(
-      element._computeStrategy({
-        ...createParsedChange(),
-        submit_type: SubmitType.REBASE_ALWAYS,
-      }),
-      'Rebase Always'
-    );
-  });
-
-  test('computed fields requirements', () => {
-    assert.isFalse(
-      element._computeShowRequirements({
-        ...createParsedChange(),
-        status: ChangeStatus.MERGED,
-      })
-    );
-    assert.isFalse(
-      element._computeShowRequirements({
-        ...createParsedChange(),
-        status: ChangeStatus.ABANDONED,
-      })
-    );
-
-    // No labels and no requirements: submit status is useless
-    assert.isFalse(
-      element._computeShowRequirements({
-        ...createParsedChange(),
-        status: ChangeStatus.NEW,
-        labels: {},
-      })
-    );
-
-    // Work in Progress: submit status should be present
-    assert.isTrue(
-      element._computeShowRequirements({
-        ...createParsedChange(),
-        status: ChangeStatus.NEW,
-        labels: {},
-        work_in_progress: true,
-      })
-    );
-
-    // We have at least one reason to display Submit Status
-    assert.isTrue(
-      element._computeShowRequirements({
-        ...createParsedChange(),
-        status: ChangeStatus.NEW,
-        labels: {
-          Verified: {
-            approved: createAccountWithId(),
-          },
-        },
-        requirements: [],
-      })
-    );
-    assert.isTrue(
-      element._computeShowRequirements({
-        ...createParsedChange(),
-        status: ChangeStatus.NEW,
-        labels: {},
-        requirements: [
-          {
-            ...createRequirement(),
-            fallbackText: 'Resolve all comments',
-            status: RequirementStatus.OK,
-          },
-        ],
-      })
-    );
-  });
-
-  test('show strategy for open change', () => {
+  test('show strategy for open change', async () => {
     element.change = {
       ...createParsedChange(),
       status: ChangeStatus.NEW,
       submit_type: SubmitType.CHERRY_PICK,
       labels: {},
     };
-    flush();
+    await element.updateComplete;
     const strategy = element.shadowRoot?.querySelector('.strategy');
     assert.ok(strategy);
     assert.isFalse(strategy?.hasAttribute('hidden'));
-    assert.equal(strategy?.children[1].innerHTML, 'Cherry Pick');
+    assert.equal(strategy?.children[1].textContent, 'Cherry Pick');
   });
 
-  test('hide strategy for closed change', () => {
+  test('hide strategy for closed change', async () => {
     element.change = {
       ...createParsedChange(),
       status: ChangeStatus.MERGED,
       labels: {},
     };
-    flush();
-    assert.isTrue(
-      element.shadowRoot?.querySelector('.strategy')?.hasAttribute('hidden')
-    );
+    await element.updateComplete;
+    assert.isNull(element.shadowRoot?.querySelector('.strategy'));
   });
 
-  test('weblinks use GerritNav interface', () => {
+  test('weblinks use GerritNav interface', async () => {
     const weblinksStub = sinon
       .stub(GerritNav, '_generateWeblinks')
       .returns([{name: 'stubb', url: '#s'}]);
     element.commitInfo = createCommitInfoWithRequiredCommit();
     element.serverConfig = createServerInfo();
-    flush();
-    const webLinks = element.$.webLinks;
+    await element.updateComplete;
+    const webLinks = element.webLinks!;
     assert.isTrue(weblinksStub.called);
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+    assert.isNotNull(webLinks);
+    assert.equal(element.computeWebLinks().length, 1);
   });
 
-  test('weblinks hidden when no weblinks', () => {
+  test('weblinks hidden when no weblinks', async () => {
     element.commitInfo = createCommitInfoWithRequiredCommit();
     element.serverConfig = createServerInfo();
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
+    await element.updateComplete;
+    assert.isNull(element.webLinks);
   });
 
-  test('weblinks hidden when only gitiles weblink', () => {
+  test('weblinks hidden when only gitiles weblink', async () => {
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
       web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
     };
     element.serverConfig = createServerInfo();
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo), null);
+    await element.updateComplete;
+    assert.isNull(element.webLinks);
+    assert.equal(element.computeWebLinks().length, 0);
   });
 
-  test('weblinks hidden when sole weblink is set as primary', () => {
+  test('weblinks hidden when sole weblink is set as primary', async () => {
     const browser = 'browser';
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
@@ -272,25 +298,24 @@
         primary_weblink_name: browser,
       },
     };
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
+    await element.updateComplete;
+    assert.isNull(element.webLinks);
   });
 
-  test('weblinks are visible when other weblinks', () => {
-    const router = document.createElement('gr-router');
+  test('weblinks are visible when other weblinks', async () => {
+    const router = new GrRouter();
     sinon
       .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
+      .callsFake(router.generateWeblinks.bind(router));
 
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
       web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
     };
-    flush();
-    const webLinks = element.$.webLinks;
+    await element.updateComplete;
+    const webLinks = element.webLinks!;
     assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+    assert.equal(element.computeWebLinks().length, 1);
     // With two non-gitiles weblinks, there are two returned.
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
@@ -299,14 +324,14 @@
         {...createWebLinkInfo(), name: 'test2', url: '#'},
       ],
     };
-    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 2);
+    assert.equal(element.computeWebLinks().length, 2);
   });
 
-  test('weblinks are visible when gitiles and other weblinks', () => {
-    const router = document.createElement('gr-router');
+  test('weblinks are visible when gitiles and other weblinks', async () => {
+    const router = new GrRouter();
     sinon
       .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
+      .callsFake(router.generateWeblinks.bind(router));
 
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
@@ -315,14 +340,14 @@
         {...createWebLinkInfo(), name: 'gitiles', url: '#'},
       ],
     };
-    flush();
-    const webLinks = element.$.webLinks;
+    await element.updateComplete;
+    const webLinks = element.webLinks!;
     assert.isFalse(webLinks.hasAttribute('hidden'));
     // Only the non-gitiles weblink is returned.
-    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+    assert.equal(element.computeWebLinks().length, 1);
   });
 
-  suite('_getNonOwnerRole', () => {
+  suite('getNonOwnerRole', () => {
     let change: ParsedChangeInfo | undefined;
 
     setup(() => {
@@ -356,95 +381,85 @@
     });
 
     suite('role=uploader', () => {
-      test('_getNonOwnerRole for uploader', () => {
-        assert.deepEqual(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
-          {
-            ...createAccountWithId(),
-            email: 'ghi@def' as EmailAddress,
-            _account_id: 1011123 as AccountId,
-          }
-        );
+      test('getNonOwnerRole for uploader', () => {
+        element.change = change;
+        assert.deepEqual(element.getNonOwnerRole(ChangeRole.UPLOADER), {
+          ...createAccountWithId(),
+          email: 'ghi@def' as EmailAddress,
+          _account_id: 1011123 as AccountId,
+        });
       });
 
-      test('_getNonOwnerRole that it does not return uploader', () => {
+      test('getNonOwnerRole that it does not return uploader', () => {
         // Set the uploader email to be the same as the owner.
         change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.UPLOADER));
       });
 
-      test('_computeShowRoleClass show uploader', () => {
-        assert.equal(
-          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
-          ''
-        );
+      test('computeShowRoleClass show uploader', () => {
+        element.change = change;
+        assert.notEqual(element.renderNonOwner(ChangeRole.UPLOADER), nothing);
       });
 
-      test('_computeShowRoleClass hide uploader', () => {
+      test('computeShowRoleClass hide uploader', () => {
         // Set the uploader email to be the same as the owner.
         change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId;
-        assert.equal(
-          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
-          'hideDisplay'
-        );
+        element.change = change;
+        assert.equal(element.renderNonOwner(ChangeRole.UPLOADER), nothing);
       });
     });
 
     suite('role=committer', () => {
-      test('_getNonOwnerRole for committer', () => {
+      test('getNonOwnerRole for committer', () => {
         change!.revisions.rev1.uploader!.email = 'ghh@def' as EmailAddress;
-        assert.deepEqual(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
-          {...createGitPerson(), email: 'ghi@def' as EmailAddress}
-        );
+        element.change = change;
+        assert.deepEqual(element.getNonOwnerRole(ChangeRole.COMMITTER), {
+          ...createGitPerson(),
+          email: 'ghi@def' as EmailAddress,
+        });
       });
 
-      test('_getNonOwnerRole is null if committer is same as uploader', () => {
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
-        );
+      test('getNonOwnerRole is null if committer is same as uploader', () => {
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
 
-      test('_getNonOwnerRole that it does not return committer', () => {
+      test('getNonOwnerRole that it does not return committer', () => {
         // Set the committer email to be the same as the owner.
         change!.revisions.rev1.commit!.committer.email =
           'abc@def' as EmailAddress;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
 
-      test('_getNonOwnerRole null for committer with no commit', () => {
+      test('getNonOwnerRole null for committer with no commit', () => {
         delete change!.revisions.rev1.commit;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
     });
 
     suite('role=author', () => {
-      test('_getNonOwnerRole for author', () => {
-        assert.deepEqual(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-          {...createGitPerson(), email: 'jkl@def' as EmailAddress}
-        );
+      test('getNonOwnerRole for author', () => {
+        element.change = change;
+        assert.deepEqual(element.getNonOwnerRole(ChangeRole.AUTHOR), {
+          ...createGitPerson(),
+          email: 'jkl@def' as EmailAddress,
+        });
       });
 
-      test('_getNonOwnerRole that it does not return author', () => {
+      test('getNonOwnerRole that it does not return author', () => {
         // Set the author email to be the same as the owner.
         change!.revisions.rev1.commit!.author.email = 'abc@def' as EmailAddress;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
       });
 
-      test('_getNonOwnerRole null for author with no commit', () => {
+      test('getNonOwnerRole null for author with no commit', () => {
         delete change!.revisions.rev1.commit;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
       });
     });
   });
@@ -489,10 +504,9 @@
           problems: ['No public keys found for key ID E5E20E52'],
         },
       };
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change
-      );
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       assert.equal(
         result?.message,
         'Push certificate is invalid:\n' +
@@ -509,10 +523,9 @@
           status: GpgKeyInfoStatus.TRUSTED,
         },
       };
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change
-      );
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       assert.equal(
         result?.message,
         'Push certificate is valid and key is trusted'
@@ -522,11 +535,10 @@
     });
 
     test('Push Certificate Validation is missing test', () => {
-      change!.revisions.rev1! = createRevision(1);
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change
-      );
+      change!.revisions.rev1 = createRevision(1);
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       assert.equal(
         result?.message,
         'This patch set was created without a push certificate'
@@ -536,7 +548,7 @@
     });
   });
 
-  test('_computeParents', () => {
+  test('computeParents', () => {
     const parents: ParentCommitInfo[] = [
       {...createCommit(), commit: '123' as CommitId, subject: 'abc'},
     ];
@@ -544,7 +556,9 @@
       ...createRevision(1),
       commit: {...createCommit(), parents},
     };
-    assert.equal(element._computeParents(undefined, revision), parents);
+    element.change = undefined;
+    element.revision = revision;
+    assert.equal(element.computeParents(), parents);
     const change = (current_revision: CommitId): ParsedChangeInfo => {
       return {
         ...createParsedChange(),
@@ -552,22 +566,25 @@
         revisions: {456: revision},
       };
     };
-    const change_bad_revision = change('789' as CommitId);
-    assert.deepEqual(
-      element._computeParents(change_bad_revision, createRevision()),
-      []
-    );
-    const change_no_commit: ParsedChangeInfo = {
+    const changebadrevision = change('789' as CommitId);
+    element.change = changebadrevision;
+    element.revision = createRevision();
+    assert.deepEqual(element.computeParents(), []);
+    const changenocommit: ParsedChangeInfo = {
       ...createParsedChange(),
       current_revision: '456' as CommitId,
       revisions: {456: createRevision()},
     };
-    assert.deepEqual(element._computeParents(change_no_commit, undefined), []);
-    const change_good = change('456' as CommitId);
-    assert.equal(element._computeParents(change_good, undefined), parents);
+    element.change = changenocommit;
+    element.revision = undefined;
+    assert.deepEqual(element.computeParents(), []);
+    const changegood = change('456' as CommitId);
+    element.change = changegood;
+    element.revision = undefined;
+    assert.equal(element.computeParents(), parents);
   });
 
-  test('_currentParents', () => {
+  test('currentParents', async () => {
     const revision = (parent: CommitId): RevisionInfo => {
       return {
         ...createRevision(),
@@ -584,93 +601,116 @@
       owner: {},
     };
     element.revision = revision('222' as CommitId);
-    assert.equal(element._currentParents[0].commit, '222');
+    await element.updateComplete;
+    assert.equal(element.currentParents[0].commit, '222');
     element.revision = revision('333' as CommitId);
-    assert.equal(element._currentParents[0].commit, '333');
+    await element.updateComplete;
+    assert.equal(element.currentParents[0].commit, '333');
     element.revision = undefined;
-    assert.equal(element._currentParents[0].commit, '111');
+    await element.updateComplete;
+    assert.equal(element.currentParents[0].commit, '111');
     element.change = createParsedChange();
-    assert.deepEqual(element._currentParents, []);
+    await element.updateComplete;
+    assert.deepEqual(element.currentParents, []);
   });
 
-  test('_computeParentsLabel', () => {
+  test('computeParentListClass', () => {
     const parent: ParentCommitInfo = {
       ...createCommit(),
       commit: 'abc123' as CommitId,
       subject: 'My parent commit',
     };
-    assert.equal(element._computeParentsLabel([parent]), 'Parent');
-    assert.equal(element._computeParentsLabel([parent, parent]), 'Parents');
-  });
-
-  test('_computeParentListClass', () => {
-    const parent: ParentCommitInfo = {
-      ...createCommit(),
-      commit: 'abc123' as CommitId,
-      subject: 'My parent commit',
-    };
+    element.currentParents = [parent];
+    element.parentIsCurrent = true;
     assert.equal(
-      element._computeParentListClass([parent], true),
+      element.computeParentListClass(),
       'parentList nonMerge current'
     );
+    element.currentParents = [parent];
+    element.parentIsCurrent = false;
     assert.equal(
-      element._computeParentListClass([parent], false),
+      element.computeParentListClass(),
       'parentList nonMerge notCurrent'
     );
+    element.currentParents = [parent, parent];
+    element.parentIsCurrent = false;
     assert.equal(
-      element._computeParentListClass([parent, parent], false),
+      element.computeParentListClass(),
       'parentList merge notCurrent'
     );
-    assert.equal(
-      element._computeParentListClass([parent, parent], true),
-      'parentList merge current'
-    );
+    element.currentParents = [parent, parent];
+    element.parentIsCurrent = true;
+    assert.equal(element.computeParentListClass(), 'parentList merge current');
   });
 
-  test('_showAddTopic', () => {
-    const changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'> =
-      {
-        base: {...createParsedChange()},
-        path: '',
-        value: undefined,
-      };
-    assert.isTrue(element._showAddTopic(undefined, false, false));
+  test('showAddTopic', () => {
+    const change = createParsedChange();
+    element.change = undefined;
+    element.settingTopic = false;
+    element.topicReadOnly = false;
+    assert.isTrue(element.showAddTopic());
     // do not show for 'readonly'
-    assert.isFalse(element._showAddTopic(undefined, false, true));
-    assert.isTrue(element._showAddTopic(changeRecord, false, false));
-    assert.isFalse(element._showAddTopic(changeRecord, true, false));
-    changeRecord.base!.topic = 'foo' as TopicName;
-    assert.isFalse(element._showAddTopic(changeRecord, true, false));
-    assert.isFalse(element._showAddTopic(changeRecord, false, false));
+    element.change = undefined;
+    element.settingTopic = false;
+    element.topicReadOnly = true;
+    assert.isFalse(element.showAddTopic());
+    element.change = change;
+    element.settingTopic = false;
+    element.topicReadOnly = false;
+    assert.isTrue(element.showAddTopic());
+    element.change = change;
+    element.settingTopic = true;
+    element.topicReadOnly = false;
+    assert.isFalse(element.showAddTopic());
+    change.topic = 'foo' as TopicName;
+    element.change = change;
+    element.settingTopic = true;
+    element.topicReadOnly = false;
+    assert.isFalse(element.showAddTopic());
+    element.change = change;
+    element.settingTopic = false;
+    element.topicReadOnly = false;
+    assert.isFalse(element.showAddTopic());
   });
 
-  test('_showTopicChip', () => {
-    const changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'> =
-      {
-        base: {...createParsedChange()},
-        path: '',
-        value: undefined,
-      };
-    assert.isFalse(element._showTopicChip(undefined, false));
-    assert.isFalse(element._showTopicChip(changeRecord, false));
-    assert.isFalse(element._showTopicChip(changeRecord, true));
-    changeRecord.base!.topic = 'foo' as TopicName;
-    assert.isFalse(element._showTopicChip(changeRecord, true));
-    assert.isTrue(element._showTopicChip(changeRecord, false));
+  test('showTopicChip', async () => {
+    const change = createParsedChange();
+    element.change = change;
+    element.settingTopic = true;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    element.change = change;
+    element.settingTopic = false;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    element.change = change;
+    element.settingTopic = true;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    change.topic = 'foo' as TopicName;
+    element.change = change;
+    element.settingTopic = true;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    element.change = change;
+    element.settingTopic = false;
+    await element.updateComplete;
+    assert.isTrue(element.showTopicChip());
   });
 
-  test('_showCherryPickOf', () => {
-    const changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'> =
-      {
-        base: {...createParsedChange()},
-        path: '',
-        value: undefined,
-      };
-    assert.isFalse(element._showCherryPickOf(undefined));
-    assert.isFalse(element._showCherryPickOf(changeRecord));
-    changeRecord.base!.cherry_pick_of_change = 123 as NumericChangeId;
-    changeRecord.base!.cherry_pick_of_patch_set = 1 as PatchSetNum;
-    assert.isTrue(element._showCherryPickOf(changeRecord));
+  test('showCherryPickOf', async () => {
+    element.change = undefined;
+    await element.updateComplete;
+    assert.isFalse(element.showCherryPickOf());
+    const change = createParsedChange();
+    element.change = change;
+    await element.updateComplete;
+    assert.isFalse(element.showCherryPickOf());
+    change.cherry_pick_of_change = 123 as NumericChangeId;
+    change.cherry_pick_of_patch_set = 1 as PatchSetNum;
+    element.change = change;
+    await element.updateComplete;
+    assert.isTrue(element.showCherryPickOf());
   });
 
   suite('Topic removal', () => {
@@ -695,22 +735,28 @@
       };
     });
 
-    test('_computeTopicReadOnly', () => {
+    test('computeTopicReadOnly', () => {
       let mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      element.mutable = mutable;
+      element.change = change;
+      assert.isTrue(element.computeTopicReadOnly());
       mutable = true;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      element.mutable = mutable;
+      assert.isTrue(element.computeTopicReadOnly());
       change!.actions!.topic!.enabled = true;
-      assert.isFalse(element._computeTopicReadOnly(mutable, change));
+      element.mutable = mutable;
+      element.change = change;
+      assert.isFalse(element.computeTopicReadOnly());
       mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      element.mutable = mutable;
+      assert.isTrue(element.computeTopicReadOnly());
     });
 
     test('topic read only hides delete button', async () => {
       element.account = createAccountDetailWithId();
       element.change = change;
       sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
-      await flush();
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
       assert.isTrue(button.hasAttribute('hidden'));
@@ -721,7 +767,7 @@
       change.actions!.topic!.enabled = true;
       element.change = change;
       sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
-      await flush();
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
       assert.isFalse(button.hasAttribute('hidden'));
@@ -748,40 +794,57 @@
       };
     });
 
-    test('_computeHashtagReadOnly', async () => {
-      await flush();
+    test('computeHashtagReadOnly', async () => {
+      await element.updateComplete;
       let mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isTrue(element.computeHashtagReadOnly());
       mutable = true;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isTrue(element.computeHashtagReadOnly());
       change!.actions!.hashtags!.enabled = true;
-      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isFalse(element.computeHashtagReadOnly());
       mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isTrue(element.computeHashtagReadOnly());
     });
 
     test('hashtag read only hides delete button', async () => {
-      await flush();
       element.account = createAccountDetailWithId();
       element.change = change;
       sinon
         .stub(GerritNav, 'getUrlForHashtag')
         .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
-      await flush();
+      await element.updateComplete;
+      assert.isTrue(element.mutable, 'Mutable');
+      assert.isFalse(
+        element.change.actions?.hashtags?.enabled,
+        'hashtags disabled'
+      );
+      assert.isTrue(element.hashtagReadOnly, 'hashtag read only');
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
+      assert.isTrue(button.hasAttribute('hidden'), 'button hidden');
     });
 
     test('hashtag not read only does not hide delete button', async () => {
-      await flush();
+      await element.updateComplete;
       element.account = createAccountDetailWithId();
       change!.actions!.hashtags!.enabled = true;
       element.change = change;
       sinon
         .stub(GerritNav, 'getUrlForHashtag')
         .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
-      await flush();
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
       assert.isFalse(button.hasAttribute('hidden'));
@@ -789,8 +852,8 @@
   });
 
   suite('remove reviewer votes', () => {
-    setup(() => {
-      sinon.stub(element, '_computeTopicReadOnly').returns(true);
+    setup(async () => {
+      sinon.stub(element, 'computeTopicReadOnly').returns(true);
       element.change = {
         ...createParsedChange(),
         topic: 'the topic' as TopicName,
@@ -803,7 +866,7 @@
         },
         removable_reviewers: [],
       };
-      flush();
+      await element.updateComplete;
     });
 
     test('changing topic', () => {
@@ -811,7 +874,7 @@
       const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
         Promise.resolve(newTopic)
       );
-      element._handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
+      element.handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
       const topicChangedSpy = sinon.spy();
       element.addEventListener('topic-changed', topicChangedSpy);
       assert.isTrue(
@@ -829,7 +892,7 @@
         Promise.resolve(newTopic)
       );
       sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:the+new+topic');
-      await flush();
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const remove = queryAndAssert(chip, '#remove');
       const topicChangedSpy = sinon.spy();
@@ -845,13 +908,14 @@
     });
 
     test('changing hashtag', async () => {
-      await flush();
-      element._newHashtag = 'new hashtag' as Hashtag;
+      await element.updateComplete;
       const newHashtag: Hashtag[] = ['new hashtag' as Hashtag];
       const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
         Promise.resolve(newHashtag)
       );
-      element._handleHashtagChanged();
+      element.handleHashtagChanged(
+        new CustomEvent('test', {detail: 'new hashtag'})
+      );
       assert.isTrue(
         setChangeHashtagStub.calledWith(42 as NumericChangeId, {
           add: ['new hashtag' as Hashtag],
@@ -869,7 +933,7 @@
       ...createParsedChange(),
       actions: {topic: {enabled: true}},
     };
-    await flush();
+    await element.updateComplete;
 
     const label = element.shadowRoot!.querySelector(
       '.topicEditableLabel'
@@ -877,38 +941,47 @@
     assert.ok(label);
     const openStub = sinon.stub(label, 'open');
     element.editTopic();
-    await flush();
+    await element.updateComplete;
 
     assert.isTrue(openStub.called);
   });
 
   suite('plugin endpoints', () => {
-    test('endpoint params', async () => {
+    setup(async () => {
+      resetPlugins();
+      element = basicFixture.instantiate();
       element.change = createParsedChange();
       element.revision = createRevision();
+      await element.updateComplete;
+    });
+
+    teardown(() => {
+      resetPlugins();
+    });
+
+    test('endpoint params', async () => {
       interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
         plugin: PluginApi;
         change: ParsedChangeInfo;
         revision: RevisionInfo;
       }
-      let hookEl: MetadataGrEndpointDecorator;
       let plugin: PluginApi;
       window.Gerrit.install(
         p => {
           plugin = p;
-          plugin
-            .hook('change-metadata-item')
-            .getLastAttached()
-            .then(el => (hookEl = el as MetadataGrEndpointDecorator));
         },
         '0.1',
         'http://some/plugins/url.js'
       );
+      await element.updateComplete;
+      const hookEl = (await plugin!
+        .hook('change-metadata-item')
+        .getLastAttached()) as MetadataGrEndpointDecorator;
       getPluginLoader().loadPlugins([]);
-      await flush();
-      assert.strictEqual(hookEl!.plugin, plugin!);
-      assert.strictEqual(hookEl!.change, element.change);
-      assert.strictEqual(hookEl!.revision, element.revision);
+      await element.updateComplete;
+      assert.strictEqual(hookEl.plugin, plugin!);
+      assert.strictEqual(hookEl.change, element.change);
+      assert.strictEqual(hookEl.revision, element.revision);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
deleted file mode 100644
index 821e1ce..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label-info/gr-label-info';
-import '../../shared/gr-limited-text/gr-limited-text';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-requirements_html';
-import {customElement, property, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  AccountInfo,
-  QuickLabelInfo,
-  Requirement,
-  RequirementType,
-  LabelNameToInfoMap,
-  LabelInfo,
-} from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {getAppContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
-import {Interaction} from '../../../constants/reporting';
-
-interface ChangeRequirement extends Requirement {
-  satisfied: boolean;
-  style: string;
-}
-
-interface ChangeWIP {
-  type: RequirementType;
-  fallback_text: string;
-  tooltip: string;
-}
-
-export interface Label {
-  labelName: string;
-  labelInfo: LabelInfo;
-  icon: string;
-  style: string;
-}
-
-@customElement('gr-change-requirements')
-export class GrChangeRequirements extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  @property({type: Array, computed: '_computeRequirements(change)'})
-  _requirements?: Array<ChangeRequirement | ChangeWIP>;
-
-  @property({type: Array})
-  _requiredLabels: Label[] = [];
-
-  @property({type: Array})
-  _optionalLabels: Label[] = [];
-
-  @property({type: Boolean, computed: '_computeShowWip(change)'})
-  _showWip?: boolean;
-
-  @property({type: Boolean})
-  _showOptionalLabels = true;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  _computeShowWip(change: ChangeInfo) {
-    return change.work_in_progress;
-  }
-
-  _computeRequirements(change: ChangeInfo) {
-    const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
-
-    if (change.requirements) {
-      for (const requirement of change.requirements) {
-        const satisfied = requirement.status === 'OK';
-        const style = this._computeRequirementClass(satisfied);
-        _requirements.push({...requirement, satisfied, style});
-      }
-    }
-    if (change.work_in_progress) {
-      _requirements.push({
-        type: 'wip' as RequirementType,
-        fallback_text: 'Work-in-progress',
-        tooltip: "Change must not be in 'Work in Progress' state.",
-      });
-    }
-
-    return _requirements;
-  }
-
-  _computeRequirementClass(requirementStatus: boolean) {
-    return requirementStatus ? 'approved' : '';
-  }
-
-  _computeRequirementIcon(requirementStatus: boolean) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
-  }
-
-  @observe('change.labels.*')
-  _computeLabels(
-    labelsRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
-    const labels = labelsRecord.base || {};
-    const allLabels: Label[] = [];
-
-    for (const label of Object.keys(labels).sort(labelCompare)) {
-      allLabels.push({
-        labelName: label,
-        icon: this._computeLabelIcon(labels[label]),
-        style: this._computeLabelClass(labels[label]),
-        labelInfo: labels[label],
-      });
-    }
-    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
-    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
-  }
-
-  /**
-   * @return The icon name, or undefined if no icon should
-   * be used.
-   */
-  _computeLabelIcon(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'gr-icons:check';
-    }
-    if (labelInfo.rejected) {
-      return 'gr-icons:close';
-    }
-    return 'gr-icons:schedule';
-  }
-
-  _computeLabelClass(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'approved';
-    }
-    if (labelInfo.rejected) {
-      return 'rejected';
-    }
-    return '';
-  }
-
-  _computeShowOptional(
-    optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
-  ) {
-    return optionalFieldsRecord.base.length ? '' : 'hidden';
-  }
-
-  _computeLabelValue(value: number) {
-    return `${value > 0 ? '+' : ''}${value}`;
-  }
-
-  _computeSectionClass(show: boolean) {
-    return show ? '' : 'hidden';
-  }
-
-  _handleShowHide() {
-    this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: 'optional labels',
-      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
-    });
-  }
-
-  _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
-    return `submit-requirement-item-${item.type}`;
-  }
-
-  _computeShowAllLabelText(_showOptionalLabels: boolean) {
-    if (_showOptionalLabels) {
-      return 'Show less';
-    } else {
-      return 'Show all';
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-requirements': GrChangeRequirements;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
deleted file mode 100644
index 8161592..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-      width: 100%;
-    }
-    .status {
-      color: var(--warning-foreground);
-      display: inline-block;
-      text-align: center;
-      vertical-align: top;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .approved.status {
-      color: var(--positive-green-text-color);
-    }
-    .rejected.status {
-      color: var(--negative-red-text-color);
-    }
-    iron-icon {
-      color: inherit;
-    }
-    .status iron-icon {
-      vertical-align: top;
-    }
-    gr-endpoint-decorator.submit-requirement-endpoints,
-    section {
-      display: table-row;
-    }
-    .show-hide {
-      float: right;
-    }
-    .title {
-      min-width: 10em;
-      padding: var(--spacing-s) var(--spacing-m) 0
-        var(--requirements-horizontal-padding);
-    }
-    .value {
-      padding: var(--spacing-s) 0 0 0;
-    }
-    .title,
-    .value {
-      display: table-cell;
-      vertical-align: top;
-    }
-    .hidden {
-      display: none;
-    }
-    .showHide {
-      cursor: pointer;
-    }
-    .showHide .title {
-      padding-bottom: var(--spacing-m);
-      padding-top: var(--spacing-l);
-    }
-    .showHide .value {
-      padding-top: 0;
-      vertical-align: middle;
-    }
-    .showHide iron-icon {
-      color: var(--deemphasized-text-color);
-      float: right;
-    }
-    .show-all-button {
-      float: right;
-    }
-    .show-all-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .spacer {
-      height: var(--spacing-m);
-    }
-    gr-endpoint-param {
-      display: none;
-    }
-    .metadata-title {
-      font-weight: var(--font-weight-bold);
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-    .title .metadata-title {
-      padding-left: 0;
-    }
-  </style>
-  <h3 class="metadata-title heading-3">Submit requirements</h3>
-  <template is="dom-repeat" items="[[_requirements]]">
-    <gr-endpoint-decorator
-      class="submit-requirement-endpoints"
-      name$="[[_computeSubmitRequirementEndpoint(item)]]"
-    >
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param name="requirement" value="[[item]]">
-      </gr-endpoint-param>
-      <div class="title requirement">
-        <span class$="status [[item.style]]">
-          <iron-icon
-            class="icon"
-            icon="[[_computeRequirementIcon(item.satisfied)]]"
-          ></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          tooltip="[[item.tooltip]]"
-          text="[[item.fallback_text]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-endpoint-slot name="value"></gr-endpoint-slot>
-      </div>
-    </gr-endpoint-decorator>
-  </template>
-  <template is="dom-repeat" items="[[_requiredLabels]]">
-    <section>
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section class="spacer"></section>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
-  ></section>
-  <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
-    <div class="title">
-      <h3 class="metadata-title">Other labels</h3>
-    </div>
-    <div class="value">
-      <gr-button link="" class="show-all-button" on-click="_handleShowHide"
-        >[[_computeShowAllLabelText(_showOptionalLabels)]]
-        <iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[_showOptionalLabels]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[!_showOptionalLabels]]"
-        ></iron-icon>
-      </gr-button>
-    </div>
-  </section>
-  <template is="dom-repeat" items="[[_optionalLabels]]">
-    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <template is="dom-if" if="[[item.icon]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </template>
-          <template is="dom-if" if="[[!item.icon]]">
-            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-          </template>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
-  ></section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
deleted file mode 100644
index 90f9d29..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-requirements.js';
-import {isHidden} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-requirements');
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('requirements computed fields', () => {
-    assert.isTrue(element._computeShowWip({work_in_progress: true}));
-    assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-    assert.equal(element._computeRequirementClass(true), 'approved');
-    assert.equal(element._computeRequirementClass(false), '');
-
-    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-    assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:schedule');
-  });
-
-  test('label computed fields', () => {
-    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
-
-    assert.equal(element._computeLabelClass({approved: []}), 'approved');
-    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-    assert.equal(element._computeLabelClass({}), '');
-    assert.equal(element._computeLabelClass({value: 0}), '');
-
-    assert.equal(element._computeLabelValue(1), '+1');
-    assert.equal(element._computeLabelValue(-1), '-1');
-    assert.equal(element._computeLabelValue(0), '0');
-  });
-
-  test('_computeLabels', () => {
-    assert.equal(element._optionalLabels.length, 0);
-    assert.equal(element._requiredLabels.length, 0);
-    element._computeLabels({base: {
-      test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        value: 1,
-      },
-      opt_test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        optional: true,
-      },
-    }});
-    assert.equal(element._optionalLabels.length, 1);
-    assert.equal(element._requiredLabels.length, 1);
-
-    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
-    assert.equal(element._optionalLabels[0].style, '');
-    assert.ok(element._optionalLabels[0].labelInfo);
-  });
-
-  test('optional show/hide', () => {
-    element._optionalLabels = [{label: 'test'}];
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('section.optional'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.show-all-button'));
-    flush();
-
-    assert.isFalse(element._showOptionalLabels);
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('section.optional')));
-  });
-
-  test('properly converts satisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: [],
-        },
-      },
-      requirements: [],
-    };
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('.approved'));
-    assert.ok(element.shadowRoot
-        .querySelector('.name'));
-    assert.equal(element.shadowRoot
-        .querySelector('.name').text, 'Verified');
-  });
-
-  test('properly converts unsatisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-    };
-    flush();
-
-    const name = element.shadowRoot
-        .querySelector('.name');
-    assert.ok(name);
-    assert.isFalse(name.hasAttribute('hidden'));
-    assert.equal(name.text, 'Verified');
-  });
-
-  test('properly displays Work In Progress', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [],
-      work_in_progress: true,
-    };
-    flush();
-
-    const changeIsWip = element.shadowRoot
-        .querySelector('.title');
-    assert.ok(changeIsWip);
-  });
-
-  test('properly displays a satisfied requirement', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.isFalse(requirement.hasAttribute('hidden'));
-    assert.ok(requirement.querySelector('.approved'));
-    assert.equal(requirement.querySelector('.name').text,
-        'Resolve all comments');
-  });
-
-  test('satisfied class is applied with OK', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.ok(requirement.querySelector('.approved'));
-  });
-
-  test('satisfied class is not applied with NOT_READY', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'NOT_READY',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-
-  test('satisfied class is not applied with RULE_ERROR', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'RULE_ERROR',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 72a6b9c..6ec815c 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
@@ -155,8 +155,8 @@
   override render() {
     const chipClass = `summaryChip font-small ${this.styleType}`;
     const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
-    return html`<button class="${chipClass}" @click="${this.handleClick}">
-      ${this.icon && html`<iron-icon icon="${grIcon}"></iron-icon>`}
+    return html`<button class=${chipClass} @click=${this.handleClick}>
+      ${this.icon && html`<iron-icon icon=${grIcon}></iron-icon>`}
       <slot></slot>
     </button>`;
   }
@@ -342,8 +342,8 @@
 
   private renderChip(clazz: string, ariaLabel: string, icon: string) {
     return html`
-      <div class="${clazz}" role="link" tabindex="0" aria-label="${ariaLabel}">
-        <iron-icon icon="${icon}"></iron-icon>
+      <div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
+        <iron-icon icon=${icon}></iron-icon>
         ${this.renderLinks()}
         <div class="text">${this.text}</div>
       </div>
@@ -354,10 +354,10 @@
     return this.links.map(
       link => html`
         <a
-          href="${link}"
+          href=${link}
           target="_blank"
-          @click="${this.onLinkClick}"
-          @keydown="${this.onLinkKeyDown}"
+          @click=${this.onLinkClick}
+          @keydown=${this.onLinkKeyDown}
           aria-label="Link to check details"
           ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
         ></a>
@@ -430,54 +430,58 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().aPluginHasRegistered$,
+      () => this.getChecksModel().aPluginHasRegistered$,
       x => (this.showChecksSummary = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingFirstTime$,
+      () => this.getChecksModel().someProvidersAreLoadingFirstTime$,
       x => (this.someProvidersAreLoading = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsLatest$,
+      () => this.getChecksModel().topLevelActionsLatest$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelMessagesLatest$,
+      () => this.getChecksModel().topLevelMessagesLatest$,
       x => (this.messages = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
+      () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threads$,
       x => (this.commentThreads = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.selfAccount = x)
+    );
   }
 
   static override get styles() {
@@ -614,7 +618,7 @@
     if (!action) return;
     return html`<gr-checks-action
       context="summary"
-      .action="${action}"
+      .action=${action}
     ></gr-checks-action>`;
   }
 
@@ -634,9 +638,9 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        .items="${items}"
-        .disabledIds="${disabledIds}"
+        @tap-item=${this.handleAction}
+        .items=${items}
+        .disabledIds=${disabledIds}
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -654,7 +658,7 @@
               <iron-icon icon="gr-icons:error"></iron-icon>
             </div>
             <div class="right">
-              <div class="message" title="${message}">
+              <div class="message" title=${message}>
                 Error while fetching results for ${plugin}: ${message}
               </div>
             </div>
@@ -675,7 +679,7 @@
           Not logged in
         </div>
         <div class="right">
-          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+          <gr-button @click=${this.loginCallback} link>Sign in</gr-button>
         </div>
       </div>
     `;
@@ -736,10 +740,10 @@
     if (count === 0) return;
     const handler = () => this.onChipClick({statusOrCategory});
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
-      .text="${`${count}`}"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      .statusOrCategory=${statusOrCategory}
+      .text=${`${count}`}
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
@@ -754,10 +758,10 @@
       this.requestUpdate();
     };
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
+      .statusOrCategory=${statusOrCategory}
       .text="+ ${count} more"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
@@ -782,11 +786,11 @@
     }
     const handler = () => this.onChipClick(tabState);
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
-      .text="${text}"
-      .links="${links}"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      .statusOrCategory=${statusOrCategory}
+      .text=${text}
+      .links=${links}
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
@@ -834,7 +838,7 @@
                   : ''}${this.renderChecksChipRunning()}
                 <span
                   class="loadingSpin"
-                  ?hidden="${!this.someProvidersAreLoading}"
+                  ?hidden=${!this.someProvidersAreLoading}
                 ></span>
                 ${this.renderErrorMessages()} ${this.renderChecksLogin()}
                 ${this.renderSummaryMessage()} ${this.renderActions()}
@@ -866,7 +870,7 @@
                 ${unresolvedAuthors.map(
                   account =>
                     html`<gr-avatar
-                      .account="${account}"
+                      .account=${account}
                       imageSize="32"
                     ></gr-avatar>`
                 )}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 349fa5e..3db1012 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -21,7 +21,6 @@
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../../shared/gr-account-link/gr-account-link';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
@@ -169,11 +168,13 @@
   SwitchTabEvent,
   SwitchTabEventDetail,
   TabState,
+  ValueChangedEvent,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {
+  fire,
   fireAlert,
   fireDialogChange,
   fireEvent,
@@ -283,7 +284,7 @@
   @property({type: Object, observer: '_paramsChanged'})
   params?: AppElementChangeViewParams;
 
-  @property({type: Object, notify: true, observer: '_viewStateChanged'})
+  @property({type: Object, observer: '_viewStateChanged'})
   viewState: Partial<ChangeViewState> = {};
 
   @property({type: String})
@@ -440,10 +441,6 @@
   })
   _changeStatuses?: ChangeStates[];
 
-  /** If false, then the "Show more" button was used to expand. */
-  @property({type: Boolean})
-  _commitCollapsed = true;
-
   /** Is the "Show more/less" button visible? */
   @property({
     type: Boolean,
@@ -636,6 +633,12 @@
 
   private connected$ = new BehaviorSubject(false);
 
+  /**
+   * For `connectedCallback()` to distinguish between connecting to the DOM for
+   * the first time or if just re-connecting.
+   */
+  private isFirstConnection = true;
+
   /** Simply reflects the router-model value. */
   // visible for testing
   routerPatchNum?: PatchSetNum;
@@ -652,12 +655,29 @@
       'fullscreen-overlay-opened',
       () => this._handleHideBackgroundContent()
     );
-
     this.addEventListener('fullscreen-overlay-closed', () =>
       this._handleShowBackgroundContent()
     );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+    this.addEventListener('change-message-deleted', () => fireReload(this));
+    this.addEventListener('editable-content-save', e =>
+      this._handleCommitMessageSave(e)
+    );
+    this.addEventListener('editable-content-cancel', () =>
+      this._handleCommitMessageCancel()
+    );
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
+
+    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
+      this._setActivePrimaryTab(e)
+    );
+    this.addEventListener('reload', e => {
+      this.loadData(
+        /* isLocationChange= */ false,
+        /* clearPatchset= */ e.detail && e.detail.clearPatchset
+      );
+    });
   }
 
   private setupSubscriptions() {
@@ -702,24 +722,23 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    this.firstConnectedCallback();
     this.connected$.next(true);
-    this.setupSubscriptions();
-    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
-      this._handleToggleChangeStar()
-    );
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-      this._replyDisabled = false;
-    });
 
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.restApiService.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-    });
+    // Make sure to reverse everything below this line in disconnectedCallback().
+    // Or consider using either firstConnectedCallback() or constructor().
+    this.setupSubscriptions();
+    document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('scroll', this.handleScroll);
+  }
+
+  /**
+   * For initialization that should only happen once, not again when
+   * re-connecting to the DOM later.
+   */
+  private firstConnectedCallback() {
+    if (!this.isFirstConnection) return;
+    this.isFirstConnection = false;
 
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -737,26 +756,21 @@
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('change-message-deleted', () => fireReload(this));
-    this.addEventListener('editable-content-save', e =>
-      this._handleCommitMessageSave(e)
+    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this._handleToggleChangeStar()
     );
-    this.addEventListener('editable-content-cancel', () =>
-      this._handleCommitMessageCancel()
-    );
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    document.addEventListener('visibilitychange', this.handleVisibilityChange);
-    document.addEventListener('scroll', this.handleScroll);
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+      this._replyDisabled = false;
+    });
 
-    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
-      this._setActivePrimaryTab(e)
-    );
-    this.addEventListener('reload', e => {
-      this.loadData(
-        /* isLocationChange= */ false,
-        /* clearPatchset= */ e.detail && e.detail.clearPatchset
-      );
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.restApiService.getAccount().then(acct => {
+          this._account = acct;
+        });
+      }
     });
   }
 
@@ -935,10 +949,20 @@
     });
   }
 
+  handleEditingChanged(e: ValueChangedEvent<boolean>) {
+    this._editingCommitMessage = e.detail.value;
+  }
+
+  handleContentChanged(e: ValueChangedEvent) {
+    this._latestCommitMessage = e.detail.value;
+  }
+
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
     assertIsDefined(this._change, '_change');
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
+    // to prevent 2 requests at the same time
+    if (this.$.commitMessageEditor.disabled) return;
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
@@ -1279,6 +1303,10 @@
     if (value.basePatchNum === undefined)
       value.basePatchNum = ParentPatchSetNum;
 
+    if (value.patchNum === undefined) {
+      value.patchNum = computeLatestPatchNum(this._allPatchSets);
+    }
+
     const patchChanged = this.hasPatchRangeChanged(value);
     let patchNumChanged = this.hasPatchNumChanged(value);
 
@@ -1503,6 +1531,9 @@
       if (this.viewState.showReplyDialog) {
         this._openReplyDialog(FocusTarget.ANY);
         this.set('viewState.showReplyDialog', false);
+        fire(this, 'view-state-change-view-changed', {
+          value: this.viewState as ChangeViewState,
+        });
       }
     });
   }
@@ -1517,6 +1548,9 @@
     }
     this.set('viewState.changeNum', this._changeNum);
     this.set('viewState.patchRange', this._patchRange);
+    fire(this, 'view-state-change-view-changed', {
+      value: this.viewState as ChangeViewState,
+    });
   }
 
   private updateTitle(change?: ChangeInfo | ParsedChangeInfo) {
@@ -1638,7 +1672,7 @@
     } else {
       const reason = getAddedByReason(this._account, this._serverConfig);
       fireAlert(this, 'Adding you to the attention set ...');
-      this._change.attention_set[this._account._account_id!] = {
+      this._change.attention_set[this._account._account_id] = {
         account: this._account,
         reason,
         reason_account: this._account,
@@ -1798,7 +1832,7 @@
   _openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
     if (!this._change) return;
     const overlay = this.$.replyOverlay;
-    overlay.open().finally(async () => {
+    overlay.open().finally(() => {
       // the following code should be executed no matter open succeed or not
       const dialog = query<GrReplyDialog>(this, '#replyDialog');
       assertIsDefined(dialog, 'reply dialog');
@@ -2051,7 +2085,7 @@
     assertIsDefined(this._changeNum, '_changeNum');
     assertIsDefined(this._patchRange?.patchNum, '_patchRange.patchNum');
     return this.restApiService
-      .getChangeCommitInfo(this._changeNum, this._patchRange!.patchNum)
+      .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
       .then(commitInfo => {
         this._commitInfo = commitInfo;
       });
@@ -2551,8 +2585,9 @@
 
   _resetReplyOverlayFocusStops() {
     const dialog = query<GrReplyDialog>(this, '#replyDialog');
-    if (!dialog) return;
-    this.$.replyOverlay.setFocusStops(dialog.getFocusStops());
+    const focusStops = dialog?.getFocusStops();
+    if (!focusStops) return;
+    this.$.replyOverlay.setFocusStops(focusStops);
   }
 
   _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
@@ -2630,11 +2665,18 @@
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
     return this.shortcuts.createTitle(shortcutName, section);
   }
+
+  _handleRevisionActionsChanged(
+    e: CustomEvent<{value: ActionNameToActionInfoMap}>
+  ) {
+    this._currentRevisionActions = e.detail.value;
+  }
 }
 
 declare global {
   interface HTMLElementEventMap {
     'toggle-star': CustomEvent<ChangeStarToggleStarDetail>;
+    'view-state-change-view-changed': ValueChangedEvent<ChangeViewState>;
   }
   interface HTMLElementTagNameMap {
     'gr-change-view': GrChangeView;
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 b78fca0..5d4db56 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
@@ -363,7 +363,7 @@
             disable-edit="[[disableEdit]]"
             has-parent="[[hasParent]]"
             actions="[[_change.actions]]"
-            revision-actions="{{_currentRevisionActions}}"
+            revision-actions="[[_currentRevisionActions]]"
             account="[[_account]]"
             change-num="[[_changeNum]]"
             change-status="[[_change.status]]"
@@ -379,6 +379,7 @@
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
             on-included-tap="_handleOpenIncludedInDialog"
+            on-revision-actions-changed="_handleRevisionActionsChanged"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
@@ -420,8 +421,10 @@
               <div id="commitMessage" class="commitMessage">
                 <gr-editable-content
                   id="commitMessageEditor"
-                  editing="{{_editingCommitMessage}}"
-                  content="{{_latestCommitMessage}}"
+                  editing="[[_editingCommitMessage]]"
+                  content="[[_latestCommitMessage]]"
+                  on-editing-changed="handleEditingChanged"
+                  on-content-changed="handleContentChanged"
                   storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
                   hide-edit-commit-message="[[_hideEditCommitMessage]]"
                   commit-collapsible="[[_commitCollapsible]]"
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 170637a..45a6530 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
@@ -34,8 +34,6 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {EventType, PluginApi} from '../../../api/plugin';
-
-import 'lodash/lodash';
 import {
   mockPromise,
   queryAndAssert,
@@ -233,6 +231,7 @@
         {
           path: '/COMMIT_MSG',
           author: {
+            // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
             _account_id: 1000000 as AccountId,
             name: 'user',
             username: 'user',
@@ -1530,7 +1529,7 @@
     );
   });
 
-  test('_handleCommitMessageSave trims trailing whitespace', () => {
+  test('_handleCommitMessageSave trims trailing whitespace', async () => {
     element._change = createChangeViewChange();
     // Response code is 500, because we want to avoid window reloading
     const putStub = stubRestApi('putChangeCommitMessage').returns(
@@ -1542,10 +1541,10 @@
 
     element._handleCommitMessageSave(mockEvent('test \n  test '));
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
-
+    element.$.commitMessageEditor.disabled = false;
     element._handleCommitMessageSave(mockEvent('  test\ntest'));
     assert.equal(putStub.lastCall.args[1], '  test\ntest');
-
+    element.$.commitMessageEditor.disabled = false;
     element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
@@ -1869,7 +1868,7 @@
     assert.equal(element._patchRange.patchNum, 5 as RevisionPatchSetNum);
   });
 
-  test('file-action-tap handling', () => {
+  test('file-action-tap handling', async () => {
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
       patchNum: 1 as RevisionPatchSetNum,
@@ -1880,10 +1879,12 @@
     const fileList = element.$.fileList;
     const Actions = GrEditConstants.Actions;
     element.$.fileListHeader.editMode = true;
+    await element.$.fileListHeader.updateComplete;
     flush();
-    const controls = element.$.fileListHeader.shadowRoot!.querySelector(
+    const controls = queryAndAssert<GrEditControls>(
+      element.$.fileListHeader,
       '#editControls'
-    ) as GrEditControls;
+    );
     const openDeleteDialogStub = sinon.stub(controls, 'openDeleteDialog');
     const openRenameDialogStub = sinon.stub(controls, 'openRenameDialog');
     const openRestoreDialogStub = sinon.stub(controls, 'openRestoreDialog');
@@ -2022,7 +2023,7 @@
   test('patch range changed', () => {
     element._patchRange = undefined;
     element._change = createChangeViewChange();
-    element._change!.revisions = createRevisions(4);
+    element._change.revisions = createRevisions(4);
     element._change.current_revision = '1' as CommitId;
     element._change = {...element._change};
 
@@ -2124,7 +2125,7 @@
     element._change = {
       ...createChangeViewChange(),
     };
-    sinon.stub(element.$.metadata, '_computeLabelNames');
+    sinon.stub(element.$.metadata, 'computeLabelNames');
     navigateToChangeStub.restore();
     const promise = mockPromise();
     sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index 0ce3b07..100b88e 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -60,12 +60,12 @@
       <a
         target="_blank"
         rel="noopener"
-        href="${this.computeCommitLink(
+        href=${this.computeCommitLink(
           this._webLink,
           this.change,
           this.commitInfo,
           this.serverConfig
-        )}"
+        )}
         >${this._computeShortHash(
           this.change,
           this.commitInfo,
@@ -74,9 +74,9 @@
       >
       <gr-copy-clipboard
         hastooltip
-        .buttonTitle="${'Copy full SHA to clipboard'}"
+        .buttonTitle=${'Copy full SHA to clipboard'}
         hideinput
-        .text="${this.commitInfo?.commit}"
+        .text=${this.commitInfo?.commit}
       >
       </gr-copy-clipboard>
     </div>`;
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
index cb6c9e4..c240a17 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
@@ -16,7 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import '../../core/gr-router/gr-router';
 import './gr-commit-info';
 import {GrCommitInfo} from './gr-commit-info';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -26,6 +25,7 @@
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {CommitId, RepoName} from '../../../types/common';
+import {GrRouter} from '../../core/gr-router/gr-router';
 
 const basicFixture = fixtureFromElement('gr-commit-info');
 
@@ -56,10 +56,10 @@
   });
 
   test('use web link when available', () => {
-    const router = document.createElement('gr-router');
+    const router = new GrRouter();
     sinon
       .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
+      .callsFake(router.generateWeblinks.bind(router));
 
     element.change = {...createChange(), labels: {}, project: '' as RepoName};
     element.commitInfo = {
@@ -74,10 +74,10 @@
   });
 
   test('does not relativize web links that begin with scheme', () => {
-    const router = document.createElement('gr-router');
+    const router = new GrRouter();
     sinon
       .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
+      .callsFake(router.generateWeblinks.bind(router));
 
     element.change = {...createChange(), labels: {}, project: '' as RepoName};
     element.commitInfo = {
@@ -92,10 +92,10 @@
   });
 
   test('ignore web links that are neither gitweb nor gitiles', () => {
-    const router = document.createElement('gr-router');
+    const router = new GrRouter();
     sinon
       .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
+      .callsFake(router.generateWeblinks.bind(router));
 
     element.change = {...createChange(), project: 'project-name' as RepoName};
     element.commitInfo = {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 75b6053..5dd0ce7 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -14,24 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html';
-import {customElement} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-confirm-cherrypick-conflict-dialog': GrConfirmCherrypickConflictDialog;
-  }
-}
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
-export class GrConfirmCherrypickConflictDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmCherrypickConflictDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -44,7 +33,44 @@
    * @event cancel
    */
 
-  _handleConfirmTap(e: Event) {
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Continue"
+        @confirm=${this.handleConfirmTap}
+        @cancel=${this.handleCancelTap}
+      >
+        <div class="header" slot="header">Cherry Pick Conflict!</div>
+        <div class="main" slot="main">
+          <span>Cherry Pick failed! (merge conflicts)</span>
+          <span
+            >Please select "Continue" to continue with conflicts or select
+            "cancel" to close the dialog.</span
+          >
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -55,7 +81,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -66,3 +92,9 @@
     );
   }
 }
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-cherrypick-conflict-dialog': GrConfirmCherrypickConflictDialog;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
deleted file mode 100644
index 5cf56b5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Continue"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Cherry Pick Conflict!</div>
-    <div class="main" slot="main">
-      <span>Cherry Pick failed! (merge conflicts)</span>
-
-      <span
-        >Please select "Continue" to continue with conflicts or select "cancel"
-        to close the dialog.</span
-      >
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
index f811619..ad89521 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
@@ -15,42 +15,60 @@
  * limitations under the License.
  */
 
+import {fixture, html} from '@open-wc/testing-helpers';
 import '../../../test/common-test-setup-karma';
 import {queryAndAssert} from '../../../utils/common-util';
-import {fireEvent} from '../../../utils/event-util';
 import './gr-confirm-cherrypick-conflict-dialog';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrConfirmCherrypickConflictDialog} from './gr-confirm-cherrypick-conflict-dialog';
-
-const basicFixture = fixtureFromElement(
-  'gr-confirm-cherrypick-conflict-dialog'
-);
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
   let element: GrConfirmCherrypickConflictDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-cherrypick-conflict-dialog></gr-confirm-cherrypick-conflict-dialog>`
+    );
   });
 
-  test('_handleConfirmTap', () => {
+  test('render', async () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-dialog confirm-label="Continue" role="dialog">
+        <div class="header" slot="header">Cherry Pick Conflict!</div>
+        <div class="main" slot="main">
+          <span>Cherry Pick failed! (merge conflicts)</span>
+          <span
+            >Please select "Continue" to continue with conflicts or select
+            "cancel" to close the dialog.</span
+          >
+        </div>
+      </gr-dialog>
+    `);
+  });
+
+  test('confirm', async () => {
     const confirmHandler = sinon.stub();
     element.addEventListener('confirm', confirmHandler);
-    const confirmTapStub = sinon.spy(element, '_handleConfirmTap');
-    fireEvent(queryAndAssert(element, 'gr-dialog'), 'confirm');
+
+    queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
+    await element.updateComplete;
+
     assert.isTrue(confirmHandler.called);
     assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(confirmTapStub.called);
-    assert.isTrue(confirmTapStub.calledOnce);
   });
 
-  test('_handleCancelTap', () => {
+  test('cancel', async () => {
     const cancelHandler = sinon.stub();
     element.addEventListener('cancel', cancelHandler);
-    const cancelTapStub = sinon.spy(element, '_handleCancelTap');
-    fireEvent(queryAndAssert(element, 'gr-dialog'), 'cancel');
+
+    queryAndAssert<GrButton>(
+      queryAndAssert<GrDialog>(element, 'gr-dialog'),
+      'gr-button#cancel'
+    )!.click();
+    await element.updateComplete;
+
     assert.isTrue(cancelHandler.called);
     assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(cancelTapStub.called);
-    assert.isTrue(cancelTapStub.calledOnce);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index d676dd9..fb8f279 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -19,8 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {
@@ -30,11 +28,24 @@
   CommitId,
   ChangeInfoId,
 } from '../../../types/common';
-import {customElement, property, observe} from '@polymer/decorators';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {HttpMethod, ChangeStatus} from '../../../constants/constants';
+import {customElement, property, query, state} from 'lit/decorators';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+  GrTypedAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  HttpMethod,
+  ChangeStatus,
+  ProgressStatus,
+} from '../../../constants/constants';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {fireEvent} from '../../../utils/event-util';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {choose} from 'lit/directives/choose';
+import {when} from 'lit/directives/when';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -43,14 +54,6 @@
   TOPIC,
 }
 
-// These values are directly displayed in the dialog to show progress of change
-export enum ProgressStatus {
-  RUNNING = 'RUNNING',
-  FAILED = 'FAILED',
-  NOT_STARTED = 'NOT STARTED',
-  SUCCESSFUL = 'SUCCESSFUL',
-}
-
 export type Statuses = {[changeId: string]: Status};
 
 interface Status {
@@ -64,18 +67,8 @@
   }
 }
 
-export interface GrConfirmCherrypickDialog {
-  $: {
-    branchInput: GrTypedAutocomplete<BranchName>;
-  };
-}
-
 @customElement('gr-confirm-cherrypick-dialog')
-export class GrConfirmCherrypickDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmCherrypickDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -112,27 +105,30 @@
   @property({type: Array})
   changes: ChangeInfo[] = [];
 
-  @property({type: Object})
-  _query?: (input: string) => Promise<{name: BranchName}[]>;
+  @state()
+  private query: AutocompleteQuery;
 
-  @property({type: Boolean})
-  _showCherryPickTopic = false;
+  @state()
+  private showCherryPickTopic = false;
 
-  @property({type: Number})
-  _changesCount?: number;
+  @state()
+  private changesCount?: number;
 
-  @property({type: Number})
-  _cherryPickType = CherryPickType.SINGLE_CHANGE;
+  @state()
+  cherryPickType = CherryPickType.SINGLE_CHANGE;
 
-  @property({type: Boolean})
-  _duplicateProjectChanges = false;
+  @state()
+  private duplicateProjectChanges = false;
 
-  @property({type: Object})
+  @state()
   // Status of each change that is being cherry picked together
-  _statuses: Statuses;
+  private statuses: Statuses;
 
-  @property({type: Boolean})
-  _invalidBranch = false;
+  @state()
+  private invalidBranch = false;
+
+  @query('#branchInput')
+  branchInput!: GrTypedAutocomplete<BranchName>;
 
   private selectedChangeIds = new Set<ChangeInfoId>();
 
@@ -142,8 +138,254 @@
 
   constructor() {
     super();
-    this._statuses = {};
-    this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+    this.statuses = {};
+    this.query = (text: string) => this.getProjectBranchesSuggestions(text);
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('branch')) {
+      this.updateBranch();
+    }
+    if (
+      changedProperties.has('changeStatus') ||
+      changedProperties.has('commitNum') ||
+      changedProperties.has('commitMessage')
+    ) {
+      this.computeMessage();
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      .main label,
+      .main input[type='text'] {
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        width: 73ch; /* Add a char to account for the border. */
+      }
+      .cherryPickTopicLayout {
+        display: flex;
+        align-items: center;
+        margin-bottom: var(--spacing-m);
+      }
+      .cherryPickSingleChange,
+      .cherryPickTopic {
+        margin-left: var(--spacing-m);
+      }
+      .cherry-pick-topic-message {
+        margin-bottom: var(--spacing-m);
+      }
+      label[for='messageInput'],
+      label[for='baseInput'] {
+        margin-top: var(--spacing-m);
+      }
+      .title {
+        font-weight: var(--font-weight-bold);
+      }
+      tr > td {
+        padding: var(--spacing-m);
+      }
+      th {
+        color: var(--deemphasized-text-color);
+      }
+      table {
+        border-collapse: collapse;
+      }
+      tr {
+        border-bottom: 1px solid var(--border-color);
+      }
+      .error {
+        color: var(--error-text-color);
+      }
+      .error-message {
+        color: var(--error-text-color);
+        margin: var(--spacing-m) 0 var(--spacing-m) 0;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Cherry Pick"
+        .cancelLabel=${this.computeCancelLabel()}
+        ?disabled=${this.computeDisableCherryPick(
+          this.cherryPickType,
+          this.duplicateProjectChanges,
+          this.statuses,
+          this.branch
+        )}
+        @confirm=${this.handleConfirmTap}
+        @cancel=${this.handleCancelTap}
+      >
+        <div class="header title" slot="header">
+          Cherry Pick Change to Another Branch
+        </div>
+        <div class="main" slot="main">
+          ${when(this.showCherryPickTopic, () =>
+            this.renderCherrypickTopicLayout()
+          )}
+          <label for="branchInput"> Cherry Pick to branch </label>
+          <gr-autocomplete
+            id="branchInput"
+            .text=${this.branch}
+            .query=${this.query}
+            placeholder="Destination branch"
+            @text-changed=${(e: BindValueChangeEvent) =>
+              (this.branch = e.detail.value as BranchName)}
+          >
+          </gr-autocomplete>
+          ${when(
+            this.invalidBranch,
+            () => html`
+              <span class="error"
+                >Branch name cannot contain space or commas.</span
+              >
+            `
+          )}
+          ${choose(this.cherryPickType, [
+            [
+              CherryPickType.SINGLE_CHANGE,
+              () => this.renderCherrypickSingleChangeInputs(),
+            ],
+            [CherryPickType.TOPIC, () => this.renderCherrypickTopicTable()],
+          ])}
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderCherrypickTopicLayout() {
+    return html`
+      <div class="cherryPickTopicLayout">
+        <input
+          name="cherryPickOptions"
+          type="radio"
+          id="cherryPickSingleChange"
+          @change=${this.handlecherryPickSingleChangeClicked}
+          checked
+        />
+        <label for="cherryPickSingleChange" class="cherryPickSingleChange">
+          Cherry Pick single change
+        </label>
+      </div>
+      <div class="cherryPickTopicLayout">
+        <input
+          name="cherryPickOptions"
+          type="radio"
+          id="cherryPickTopic"
+          @change=${this.handlecherryPickTopicClicked}
+        />
+        <label for="cherryPickTopic" class="cherryPickTopic">
+          Cherry Pick entire topic (${this.changesCount} Changes)
+        </label>
+      </div>
+    `;
+  }
+
+  private renderCherrypickSingleChangeInputs() {
+    return html`
+      <label for="baseInput"> Provide base commit sha1 for cherry-pick </label>
+      <iron-input
+        .bindValue=${this.baseCommit}
+        @bind-value-changed=${(e: BindValueChangeEvent) =>
+          (this.baseCommit = e.detail.value)}
+      >
+        <input
+          is="iron-input"
+          id="baseCommitInput"
+          maxlength="40"
+          placeholder="(optional)"
+        />
+      </iron-input>
+      <label for="messageInput"> Cherry Pick Commit Message </label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        rows="4"
+        .maxRows=${15}
+        .bindValue=${this.message}
+        @bind-value-changed=${(e: BindValueChangeEvent) =>
+          (this.message = e.detail.value)}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  private renderCherrypickTopicTable() {
+    return html`
+      <span class="error-message">${this.computeTopicErrorMessage()}</span>
+      <span class="cherry-pick-topic-message">
+        Commit Message will be auto generated
+      </span>
+      <table>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Change</th>
+            <th>Status</th>
+            <th>Subject</th>
+            <th>Project</th>
+            <th>Progress</th>
+            <!-- Error Message -->
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${this.changes.map(
+            item => html`
+              <tr>
+                <td>
+                  <input
+                    type="checkbox"
+                    data-item=${item.id as string}
+                    @change=${this.toggleChangeSelected}
+                    ?checked=${this.isChangeSelected(item.id)}
+                  />
+                </td>
+                <td><span> ${this.getChangeId(item)} </span></td>
+                <td><span> ${item.status} </span></td>
+                <td>
+                  <span> ${this.getTrimmedChangeSubject(item.subject)} </span>
+                </td>
+                <td><span> ${item.project} </span></td>
+                <td>
+                  <span class=${this.computeStatusClass(item, this.statuses)}>
+                    ${this.computeStatus(item, this.statuses)}
+                  </span>
+                </td>
+                <td>
+                  <span class="error">
+                    ${this.computeError(item, this.statuses)}
+                  </span>
+                </td>
+              </tr>
+            `
+          )}
+        </tbody>
+      </table>
+    `;
   }
 
   containsDuplicateProject(changes: ChangeInfo[]) {
@@ -160,28 +402,27 @@
 
   updateChanges(changes: ChangeInfo[]) {
     this.changes = changes;
-    this._statuses = {};
+    this.statuses = {};
     changes.forEach(change => {
       this.selectedChangeIds.add(change.id);
     });
-    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
-    this._changesCount = changes.length;
-    this._showCherryPickTopic = changes.length > 1;
+    this.duplicateProjectChanges = this.containsDuplicateProject(changes);
+    this.changesCount = changes.length;
+    this.showCherryPickTopic = changes.length > 1;
   }
 
-  @observe('branch')
-  _updateBranch(branch: string) {
+  private updateBranch() {
     const invalidChars = [',', ' '];
-    this._invalidBranch = !!(
-      branch && invalidChars.some(c => branch.includes(c))
+    this.invalidBranch = !!(
+      this.branch && invalidChars.some(c => this.branch.includes(c))
     );
   }
 
-  _isChangeSelected(changeId: ChangeInfoId) {
+  private isChangeSelected(changeId: ChangeInfoId) {
     return this.selectedChangeIds.has(changeId);
   }
 
-  _toggleChangeSelected(e: Event) {
+  private toggleChangeSelected(e: Event) {
     const changeId = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
       'item'
     ]! as ChangeInfoId;
@@ -191,32 +432,32 @@
     const changes = this.changes.filter(change =>
       this.selectedChangeIds.has(change.id)
     );
-    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
+    this.duplicateProjectChanges = this.containsDuplicateProject(changes);
   }
 
-  _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
-    if (duplicateProjectChanges) {
+  private computeTopicErrorMessage() {
+    if (this.duplicateProjectChanges) {
       return 'Two changes cannot be of the same project';
     }
     return '';
   }
 
   updateStatus(change: ChangeInfo, status: Status) {
-    this._statuses = {...this._statuses, [change.id]: status};
+    this.statuses = {...this.statuses, [change.id]: status};
   }
 
-  _computeStatus(change: ChangeInfo, statuses: Statuses) {
+  private computeStatus(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id])
       return ProgressStatus.NOT_STARTED;
     return statuses[change.id].status;
   }
 
-  _computeStatusClass(change: ChangeInfo, statuses: Statuses) {
+  computeStatusClass(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
     return statuses[change.id].status === ProgressStatus.FAILED ? 'error' : '';
   }
 
-  _computeError(change: ChangeInfo, statuses: Statuses) {
+  private computeError(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
     if (statuses[change.id].status === ProgressStatus.FAILED) {
       return statuses[change.id].msg;
@@ -224,24 +465,24 @@
     return '';
   }
 
-  _getChangeId(change: ChangeInfo) {
+  private getChangeId(change: ChangeInfo) {
     return change.change_id.substring(0, 10);
   }
 
-  _getTrimmedChangeSubject(subject: string) {
+  private getTrimmedChangeSubject(subject: string) {
     if (!subject) return '';
     if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
     return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
   }
 
-  _computeCancelLabel(statuses: Statuses) {
-    const isRunningChange = Object.values(statuses).some(
+  private computeCancelLabel() {
+    const isRunningChange = Object.values(this.statuses).some(
       v => v.status === ProgressStatus.RUNNING
     );
     return isRunningChange ? 'Close' : 'Cancel';
   }
 
-  _computeDisableCherryPick(
+  private computeDisableCherryPick(
     cherryPickType: CherryPickType,
     duplicateProjectChanges: boolean,
     statuses: Statuses,
@@ -258,64 +499,54 @@
     return isRunningChange;
   }
 
-  _computeIfSinglecherryPick(cherryPickType: CherryPickType) {
-    return cherryPickType === CherryPickType.SINGLE_CHANGE;
-  }
-
-  _computeIfCherryPickTopic(cherryPickType: CherryPickType) {
-    return cherryPickType === CherryPickType.TOPIC;
-  }
-
-  _handlecherryPickSingleChangeClicked() {
-    this._cherryPickType = CherryPickType.SINGLE_CHANGE;
+  private handlecherryPickSingleChangeClicked() {
+    this.cherryPickType = CherryPickType.SINGLE_CHANGE;
     fireEvent(this, 'iron-resize');
   }
 
-  _handlecherryPickTopicClicked() {
-    this._cherryPickType = CherryPickType.TOPIC;
+  private handlecherryPickTopicClicked() {
+    this.cherryPickType = CherryPickType.TOPIC;
     fireEvent(this, 'iron-resize');
   }
 
-  @observe('changeStatus', 'commitNum', 'commitMessage')
-  _computeMessage(
-    changeStatus?: string,
-    commitNum?: number,
-    commitMessage?: string
-  ) {
+  private computeMessage() {
     // Polymer 2: check for undefined
     if (
-      changeStatus === undefined ||
-      commitNum === undefined ||
-      commitMessage === undefined
+      this.changeStatus === undefined ||
+      this.commitNum === undefined ||
+      this.commitMessage === undefined
     ) {
       return;
     }
 
-    let newMessage = commitMessage;
+    let newMessage = this.commitMessage;
 
-    if (changeStatus === 'MERGED') {
+    if (this.changeStatus === 'MERGED') {
       if (!newMessage.endsWith('\n')) {
         newMessage += '\n';
       }
-      newMessage += '(cherry picked from commit ' + commitNum.toString() + ')';
+      newMessage += '(cherry picked from commit ' + this.commitNum + ')';
     }
     this.message = newMessage;
   }
 
-  _generateRandomCherryPickTopic(change: ChangeInfo) {
+  private generateRandomCherryPickTopic(change: ChangeInfo) {
     const randomString = Math.random().toString(36).substr(2, 10);
     const message = `cherrypick-${change.topic}-${randomString}`;
     return message;
   }
 
-  _handleCherryPickFailed(change: ChangeInfo, response?: Response | null) {
+  private handleCherryPickFailed(
+    change: ChangeInfo,
+    response?: Response | null
+  ) {
     if (!response) return;
     response.text().then((errText: string) => {
       this.updateStatus(change, {status: ProgressStatus.FAILED, msg: errText});
     });
   }
 
-  _handleCherryPickTopic() {
+  private handleCherryPickTopic() {
     const changes = this.changes.filter(change =>
       this.selectedChangeIds.has(change.id)
     );
@@ -324,7 +555,7 @@
       errorSpan!.innerHTML = 'No change selected';
       return;
     }
-    const topic = this._generateRandomCherryPickTopic(changes[0]);
+    const topic = this.generateRandomCherryPickTopic(changes[0]);
     changes.forEach(change => {
       this.updateStatus(change, {status: ProgressStatus.RUNNING});
       const payload = {
@@ -335,7 +566,7 @@
         allow_empty: true,
       };
       const handleError = (response?: Response | null) => {
-        this._handleCherryPickFailed(change, response);
+        this.handleCherryPickFailed(change, response);
       };
       // revisions and current_revision must exist hence casting
       const patchNum = change.revisions![change.current_revision!]._number;
@@ -350,7 +581,7 @@
         )
         .then(() => {
           this.updateStatus(change, {status: ProgressStatus.SUCCESSFUL});
-          const failedOrPending = Object.values(this._statuses).find(
+          const failedOrPending = Object.values(this.statuses).find(
             v => v.status !== ProgressStatus.SUCCESSFUL
           );
           if (!failedOrPending) {
@@ -362,12 +593,12 @@
     });
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    if (this._cherryPickType === CherryPickType.TOPIC) {
+    if (this.cherryPickType === CherryPickType.TOPIC) {
       this.reporting.reportInteraction('cherry-pick-topic-clicked', {});
-      this._handleCherryPickTopic();
+      this.handleCherryPickTopic();
       return;
     }
     // Cherry pick single change
@@ -379,7 +610,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -391,10 +622,12 @@
   }
 
   resetFocus() {
-    this.$.branchInput.focus();
+    this.branchInput.focus();
   }
 
-  _getProjectBranchesSuggestions(input: string) {
+  async getProjectBranchesSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
     if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
deleted file mode 100644
index d42f7e5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .main label,
-    .main input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-    .cherryPickTopicLayout {
-      display: flex;
-      align-items: center;
-      margin-bottom: var(--spacing-m);
-    }
-    .cherryPickSingleChange,
-    .cherryPickTopic {
-      margin-left: var(--spacing-m);
-    }
-    .cherry-pick-topic-message {
-      margin-bottom: var(--spacing-m);
-    }
-    label[for='messageInput'],
-    label[for='baseInput'] {
-      margin-top: var(--spacing-m);
-    }
-    .title {
-      font-weight: var(--font-weight-bold);
-    }
-    tr > td {
-      padding: var(--spacing-m);
-    }
-    th {
-      color: var(--deemphasized-text-color);
-    }
-    table {
-      border-collapse: collapse;
-    }
-    tr {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .error {
-      color: var(--error-text-color);
-    }
-    .error-message {
-      color: var(--error-text-color);
-      margin: var(--spacing-m) 0 var(--spacing-m) 0;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Cherry Pick"
-    cancel-label="[[_computeCancelLabel(_statuses)]]"
-    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses, branch)]]"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header title" slot="header">
-      Cherry Pick Change to Another Branch
-    </div>
-    <div class="main" slot="main">
-      <template is="dom-if" if="[[_showCherryPickTopic]]">
-        <div class="cherryPickTopicLayout">
-          <input
-            name="cherryPickOptions"
-            type="radio"
-            id="cherryPickSingleChange"
-            on-change="_handlecherryPickSingleChangeClicked"
-            checked=""
-          />
-          <label for="cherryPickSingleChange" class="cherryPickSingleChange">
-            Cherry Pick single change
-          </label>
-        </div>
-        <div class="cherryPickTopicLayout">
-          <input
-            name="cherryPickOptions"
-            type="radio"
-            id="cherryPickTopic"
-            on-change="_handlecherryPickTopicClicked"
-          />
-          <label for="cherryPickTopic" class="cherryPickTopic">
-            Cherry Pick entire topic ([[_changesCount]] Changes)
-          </label>
-        </div></template
-      >
-
-      <label for="branchInput"> Cherry Pick to branch </label>
-      <gr-autocomplete
-        id="branchInput"
-        text="{{branch}}"
-        query="[[_query]]"
-        placeholder="Destination branch"
-      >
-      </gr-autocomplete>
-      <template is="dom-if" if="[[_invalidBranch]]">
-        <span class="error"> Branch name cannot contain space or commas. </span>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
-      >
-        <label for="baseInput">
-          Provide base commit sha1 for cherry-pick
-        </label>
-        <iron-input
-          maxlength="40"
-          placeholder="(optional)"
-          bind-value="{{baseCommit}}"
-        >
-          <input
-            is="iron-input"
-            id="baseCommitInput"
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}"
-          />
-        </iron-input>
-        <label for="messageInput"> Cherry Pick Commit Message </label>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
-      >
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          rows="4"
-          max-rows="15"
-          bind-value="{{message}}"
-        ></iron-autogrow-textarea>
-      </template>
-      <template is="dom-if" if="[[_computeIfCherryPickTopic(_cherryPickType)]]">
-        <span class="error-message"
-          >[[_computeTopicErrorMessage(_duplicateProjectChanges)]]</span
-        >
-        <span class="cherry-pick-topic-message">
-          Commit Message will be auto generated
-        </span>
-        <table>
-          <thead>
-            <tr>
-              <th></th>
-              <th>Change</th>
-              <th>Status</th>
-              <th>Subject</th>
-              <th>Project</th>
-              <th>Progress</th>
-              <!-- Error Message -->
-              <th></th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[changes]]">
-              <tr>
-                <td>
-                  <input
-                    type="checkbox"
-                    data-item$="[[item.id]]"
-                    on-change="_toggleChangeSelected"
-                    checked="[[_isChangeSelected(item.id)]]"
-                  />
-                </td>
-                <td><span> [[_getChangeId(item)]] </span></td>
-                <td><span> [[item.status]] </span></td>
-                <td>
-                  <span> [[_getTrimmedChangeSubject(item.subject)]] </span>
-                </td>
-                <td><span> [[item.project]] </span></td>
-                <td>
-                  <span class$="[[_computeStatusClass(item, _statuses)]]">
-                    [[_computeStatus(item, _statuses)]]
-                  </span>
-                </td>
-                <td>
-                  <span class="error">
-                    [[_computeError(item, _statuses)]]
-                  </span>
-                </td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-      </template>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
index dd00cd1..45e13b2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -18,10 +18,7 @@
 import '../../../test/common-test-setup-karma';
 import './gr-confirm-cherrypick-dialog';
 import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
-import {
-  GrConfirmCherrypickDialog,
-  ProgressStatus,
-} from './gr-confirm-cherrypick-dialog';
+import {GrConfirmCherrypickDialog} from './gr-confirm-cherrypick-dialog';
 import {
   BranchName,
   ChangeId,
@@ -38,8 +35,8 @@
 import {createChange, createRevision} from '../../../test/test-data-generators';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog.js';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-confirm-cherrypick-dialog');
+import {ProgressStatus} from '../../../constants/constants';
+import {fixture, html} from '@open-wc/testing-helpers';
 
 const CHERRY_PICK_TYPES = {
   SINGLE_CHANGE: 1,
@@ -48,7 +45,7 @@
 suite('gr-confirm-cherrypick-dialog tests', () => {
   let element: GrConfirmCherrypickDialog;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getRepoBranches').callsFake(input => {
       if (input.startsWith('test')) {
         return Promise.resolve([
@@ -62,53 +59,59 @@
         return Promise.resolve([]);
       }
     });
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-confirm-cherrypick-dialog></gr-confirm-cherrypick-dialog>`
+    );
     element.project = 'test-project' as RepoName;
   });
 
-  test('with message missing newline', () => {
+  test('with message missing newline', async () => {
     element.changeStatus = ChangeStatus.MERGED;
     element.commitMessage = 'message';
     element.commitNum = '123' as CommitId;
     element.branch = 'master' as BranchName;
-    flush();
+    await element.updateComplete;
     const expectedMessage = 'message\n(cherry picked from commit 123)';
     assert.equal(element.message, expectedMessage);
   });
 
-  test('with merged change', () => {
+  test('with merged change', async () => {
     element.changeStatus = ChangeStatus.MERGED;
     element.commitMessage = 'message\n';
     element.commitNum = '123' as CommitId;
     element.branch = 'master' as BranchName;
-    flush();
+    await element.updateComplete;
     const expectedMessage = 'message\n(cherry picked from commit 123)';
     assert.equal(element.message, expectedMessage);
   });
 
-  test('with unmerged change', () => {
+  test('with unmerged change', async () => {
     element.changeStatus = ChangeStatus.NEW;
     element.commitMessage = 'message\n';
     element.commitNum = '123' as CommitId;
     element.branch = 'master' as BranchName;
-    flush();
+    await element.updateComplete;
+
     const expectedMessage = 'message\n';
     assert.equal(element.message, expectedMessage);
   });
 
-  test('with updated commit message', () => {
+  test('with updated commit message', async () => {
     element.changeStatus = ChangeStatus.NEW;
     element.commitMessage = 'message\n';
     element.commitNum = '123' as CommitId;
     element.branch = 'master' as BranchName;
+    await element.updateComplete;
+
     const myNewMessage = 'updated commit message';
     element.message = myNewMessage;
-    flush();
+    await element.updateComplete;
+
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions('asdf');
+  test('getProjectBranchesSuggestions empty', async () => {
+    const branches = await element.getProjectBranchesSuggestions('asdf');
     assert.isEmpty(branches);
   });
 
@@ -143,20 +146,18 @@
     ];
     setup(async () => {
       element.updateChanges(changes);
-      element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
-      await flush();
+      element.cherryPickType = CHERRY_PICK_TYPES.TOPIC;
+      await element.updateComplete;
     });
 
     test('cherry pick topic submit', async () => {
       element.branch = 'master' as BranchName;
-      await flush();
+      await element.updateComplete;
       const executeChangeActionStub = stubRestApi(
         'executeChangeAction'
       ).returns(Promise.resolve(new Response()));
-      MockInteractions.tap(
-        queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!
-      );
-      await flush();
+      queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
+      await element.updateComplete;
       const args = executeChangeActionStub.args[0];
       assert.equal(args[0], 1);
       assert.equal(args[1], 'POST' as HttpMethod);
@@ -172,7 +173,7 @@
         'containsDuplicateProject'
       );
       element.branch = 'master' as BranchName;
-      await flush();
+      await element.updateComplete;
       const executeChangeActionStub = stubRestApi(
         'executeChangeAction'
       ).returns(Promise.resolve(new Response()));
@@ -183,17 +184,15 @@
       assert.equal(checkboxes.length, 2);
       assert.isTrue(checkboxes[0].checked);
       MockInteractions.tap(checkboxes[0]);
-      MockInteractions.tap(
-        queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!
-      );
-      await flush();
+      queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
+      await element.updateComplete;
       assert.equal(executeChangeActionStub.callCount, 1);
       assert.isTrue(duplicateChangesStub.called);
     });
 
     test('deselecting all change shows error message', async () => {
       element.branch = 'master' as BranchName;
-      await flush();
+      await element.updateComplete;
       const executeChangeActionStub = stubRestApi(
         'executeChangeAction'
       ).returns(Promise.resolve(new Response()));
@@ -207,7 +206,7 @@
       MockInteractions.tap(
         queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!
       );
-      await flush();
+      await element.updateComplete;
       assert.equal(executeChangeActionStub.callCount, 0);
       assert.equal(
         queryAndAssert<HTMLElement>(element, '.error-message').innerText,
@@ -215,16 +214,16 @@
       );
     });
 
-    test('_computeStatusClass', () => {
+    test('computeStatusClass', async () => {
       assert.equal(
-        element._computeStatusClass(
+        element.computeStatusClass(
           {...createChange(), id: '1' as ChangeInfoId},
           {1: {status: ProgressStatus.RUNNING}}
         ),
         ''
       );
       assert.equal(
-        element._computeStatusClass(
+        element.computeStatusClass(
           {...createChange(), id: '1' as ChangeInfoId},
           {1: {status: ProgressStatus.FAILED}}
         ),
@@ -239,24 +238,24 @@
       ).confirmButton;
       assert.isTrue(confirmButton!.hasAttribute('disabled'));
       element.branch = 'b' as BranchName;
-      await flush();
+      await element.updateComplete;
       assert.isFalse(confirmButton!.hasAttribute('disabled'));
       element.updateStatus(changes[0], {status: ProgressStatus.RUNNING});
-      await flush();
+      await element.updateComplete;
       assert.isTrue(confirmButton!.hasAttribute('disabled'));
     });
   });
 
-  test('resetFocus', () => {
-    const focusStub = sinon.stub(element.$.branchInput, 'focus');
+  test('resetFocus', async () => {
+    const focusStub = sinon.stub(element.branchInput, 'focus');
     element.resetFocus();
+    await element.updateComplete;
+
     assert.isTrue(focusStub.called);
   });
 
-  test('_getProjectBranchesSuggestions non-empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-      'test-branch'
-    );
+  test('getProjectBranchesSuggestions non-empty', async () => {
+    const branches = await element.getProjectBranchesSuggestions('test-branch');
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index c34c577..9e2b2f6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -14,33 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import '../../shared/gr-autocomplete/gr-autocomplete';
-import '../../shared/gr-dialog/gr-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-move-dialog_html';
-import {customElement, property} from '@polymer/decorators';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 import {BranchName, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import {ValueChangedEvent} from '../../../types/events';
 
 const SUGGESTIONS_LIMIT = 15;
 
-// This is used to make sure 'branch'
-// can be typed as BranchName.
-export interface GrConfirmMoveDialog {
-  $: {
-    branchInput: GrTypedAutocomplete<BranchName>;
-  };
-}
-
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmMoveDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -62,9 +50,6 @@
   @property({type: String})
   project?: RepoName;
 
-  @property({type: Object})
-  _query?: (input: string) => Promise<{name: BranchName}[]>;
-
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
@@ -78,24 +63,90 @@
     super.connectedCallback();
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, e =>
-        this._handleConfirmTap(e)
+        this.handleConfirmTap(e)
       )
     );
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, e =>
-        this._handleConfirmTap(e)
+        this.handleConfirmTap(e)
       )
     );
   }
 
   private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          width: 30em;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        label {
+          cursor: pointer;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        .main label,
+        .main input[type='text'] {
+          display: block;
+          width: 100%;
+        }
+        .main .message {
+          width: 100%;
+        }
+        .warning {
+          color: var(--error-text-color);
+        }
+      `,
+    ];
   }
 
-  _handleConfirmTap(e: Event) {
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Move Change"
+        @confirm=${(e: Event) => this.handleConfirmTap(e)}
+        @cancel=${(e: Event) => this.handleCancelTap(e)}
+      >
+        <div class="header" slot="header">Move Change to Another Branch</div>
+        <div class="main" slot="main">
+          <p class="warning">
+            Warning: moving a change will not change its parents.
+          </p>
+          <label for="branchInput"> Move change to branch </label>
+          <gr-autocomplete
+            id="branchInput"
+            .text=${this.branch}
+            @text-changed=${(e: ValueChangedEvent) =>
+              (this.branch = e.detail.value as BranchName)}
+            .query=${(text: string) => this.getProjectBranchesSuggestions(text)}
+            placeholder="Destination branch"
+          >
+          </gr-autocomplete>
+          <label for="messageInput"> Move Change Message </label>
+          <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            .rows=${4}
+            .maxRows=${15}
+            .bindValue=${this.message}
+          ></iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -106,7 +157,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -117,7 +168,7 @@
     );
   }
 
-  _getProjectBranchesSuggestions(input: string) {
+  private getProjectBranchesSuggestions(input: string) {
     if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
deleted file mode 100644
index 7c3c719..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .main label,
-    .main input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .main .message {
-      width: 100%;
-    }
-    .warning {
-      color: var(--error-text-color);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Move Change"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Move Change to Another Branch</div>
-    <div class="main" slot="main">
-      <p class="warning">
-        Warning: moving a change will not change its parents.
-      </p>
-      <label for="branchInput"> Move change to branch </label>
-      <gr-autocomplete
-        id="branchInput"
-        text="{{branch}}"
-        query="[[_query]]"
-        placeholder="Destination branch"
-      >
-      </gr-autocomplete>
-      <label for="messageInput"> Move Change Message </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        rows="4"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
index ea5d320..af75b48 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
@@ -18,15 +18,15 @@
 import '../../../test/common-test-setup-karma';
 import './gr-confirm-move-dialog';
 import {GrConfirmMoveDialog} from './gr-confirm-move-dialog';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 suite('gr-confirm-move-dialog tests', () => {
   let element: GrConfirmMoveDialog;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getRepoBranches').callsFake((input: string) => {
       if (input.startsWith('test')) {
         return Promise.resolve([
@@ -40,35 +40,70 @@
         return Promise.resolve([]);
       }
     });
-    element = basicFixture.instantiate();
-    element.project = 'test-repo' as RepoName;
+    element = await fixture(
+      html`<gr-confirm-move-dialog
+        .project=${'test-repo' as RepoName}
+      ></gr-confirm-move-dialog>`
+    );
   });
 
-  test('with updated commit message', () => {
+  test('render', async () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-dialog confirm-label="Move Change" role="dialog">
+        <div class="header" slot="header">Move Change to Another Branch</div>
+        <div class="main" slot="main">
+          <p class="warning">
+            Warning: moving a change will not change its parents.
+          </p>
+          <label for="branchInput"> Move change to branch </label>
+          <gr-autocomplete id="branchInput" placeholder="Destination branch">
+          </gr-autocomplete>
+          <label for="messageInput"> Move Change Message </label>
+          <iron-autogrow-textarea
+            aria-disabled="false"
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+          ></iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `);
+  });
+
+  test('with updated commit message', async () => {
     element.branch = 'master' as BranchName;
     const myNewMessage = 'updated commit message';
     element.message = myNewMessage;
-    flush();
+    await element.updateComplete;
+
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-      'nonexistent'
+  test('suggestions empty', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
     );
+    const branches = await autoComplete.query!('nonexistent');
     assert.equal(branches.length, 0);
   });
 
-  test('_getProjectBranchesSuggestions non-empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-      'test-branch'
+  test('suggestions non-empty', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
     );
+    const branches = await autoComplete.query!('test-branch');
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
 
-  test('_getProjectBranchesSuggestions input empty string', async () => {
-    const branches = await element._getProjectBranchesSuggestions('');
+  test('suggestions input empty string', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
+    );
+    const branches = await autoComplete.query!('');
     assert.equal(branches.length, 0);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 00f65b0..25c403f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -14,20 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-autocomplete/gr-autocomplete';
-import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 import {NumericChangeId, BranchName} from '../../../types/common';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-autocomplete/gr-autocomplete';
 import {
   GrAutocomplete,
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
-import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ValueChangedEvent} from '../../../types/events';
 
 export interface RebaseChange {
   name: string;
@@ -38,26 +37,8 @@
   base: string | null;
 }
 
-export interface GrConfirmRebaseDialog {
-  $: {
-    confirmDialog: GrDialog;
-    parentInput: GrAutocomplete;
-    parentUpToDateMsg: HTMLDivElement;
-    rebaseOnParent: HTMLDivElement;
-    rebaseOnParentInput: HTMLInputElement;
-    rebaseOnOtherInput: HTMLInputElement;
-    rebaseOnTip: HTMLDivElement;
-    rebaseOnTipInput: HTMLInputElement;
-    tipUpToDateMsg: HTMLDivElement;
-  };
-}
-
 @customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmRebaseDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -82,20 +63,156 @@
   @property({type: Boolean})
   rebaseOnCurrent?: boolean;
 
-  @property({type: String})
-  _text = '';
+  @state()
+  text = '';
 
-  @property({type: Object})
-  _query: AutocompleteQuery = () => Promise.resolve([]);
+  @state()
+  private query: AutocompleteQuery;
 
-  @property({type: Array})
-  _recentChanges?: RebaseChange[];
+  @state()
+  recentChanges?: RebaseChange[];
+
+  @query('#rebaseOnParentInput')
+  private rebaseOnParentInput!: HTMLInputElement;
+
+  @query('#rebaseOnTipInput')
+  private rebaseOnTipInput!: HTMLInputElement;
+
+  @query('#rebaseOnOtherInput')
+  rebaseOnOtherInput!: HTMLInputElement;
+
+  @query('#parentInput')
+  parentInput!: GrAutocomplete;
 
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = input => this._getChangeSuggestions(input);
+    this.query = input => this.getChangeSuggestions(input);
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('rebaseOnCurrent') ||
+      changedProperties.has('hasParent')
+    ) {
+      this.updateSelectedOption();
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+        width: 30em;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      .message {
+        font-style: italic;
+      }
+      .parentRevisionContainer label,
+      .parentRevisionContainer input[type='text'] {
+        display: block;
+        width: 100%;
+      }
+      .parentRevisionContainer label {
+        margin-bottom: var(--spacing-xs);
+      }
+      .rebaseOption {
+        margin: var(--spacing-m) 0;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        id="confirmDialog"
+        confirm-label="Rebase"
+        @confirm=${this.handleConfirmTap}
+        @cancel=${this.handleCancelTap}
+      >
+        <div class="header" slot="header">Confirm rebase</div>
+        <div class="main" slot="main">
+          <div
+            id="rebaseOnParent"
+            class="rebaseOption"
+            ?hidden=${!this.displayParentOption()}
+          >
+            <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
+            <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
+              Rebase on parent change
+            </label>
+          </div>
+          <div
+            id="parentUpToDateMsg"
+            class="message"
+            ?hidden=${!this.displayParentUpToDateMsg()}
+          >
+            This change is up to date with its parent.
+          </div>
+          <div
+            id="rebaseOnTip"
+            class="rebaseOption"
+            ?hidden=${!this.displayTipOption()}
+          >
+            <input
+              id="rebaseOnTipInput"
+              name="rebaseOptions"
+              type="radio"
+              ?disabled=${!this.displayTipOption()}
+            />
+            <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
+              Rebase on top of the ${this.branch} branch<span
+                ?hidden=${!this.hasParent}
+              >
+                (breaks relation chain)
+              </span>
+            </label>
+          </div>
+          <div
+            id="tipUpToDateMsg"
+            class="message"
+            ?hidden=${this.displayTipOption()}
+          >
+            Change is up to date with the target branch already (${this.branch})
+          </div>
+          <div id="rebaseOnOther" class="rebaseOption">
+            <input
+              id="rebaseOnOtherInput"
+              name="rebaseOptions"
+              type="radio"
+              @click=${this.handleRebaseOnOther}
+            />
+            <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
+              Rebase on a specific change, ref, or commit
+              <span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
+            </label>
+          </div>
+          <div class="parentRevisionContainer">
+            <gr-autocomplete
+              id="parentInput"
+              .query=${this.query}
+              no-debounce
+              .text=${this.text}
+              @text-changed=${(e: ValueChangedEvent) =>
+                (this.text = e.detail.value)}
+              @click=${this.handleEnterChangeNumberClick}
+              allow-non-suggested-values
+              placeholder="Change number, ref, or commit hash"
+            >
+            </gr-autocomplete>
+          </div>
+        </div>
+      </gr-dialog>
+    `;
   }
 
   // This is called by gr-change-actions every time the rebase dialog is
@@ -116,25 +233,25 @@
             value: change._number,
           });
         }
-        this._recentChanges = changes;
-        return this._recentChanges;
+        this.recentChanges = changes;
+        return this.recentChanges;
       });
   }
 
-  _getRecentChanges() {
-    if (this._recentChanges) {
-      return Promise.resolve(this._recentChanges);
+  getRecentChanges() {
+    if (this.recentChanges) {
+      return Promise.resolve(this.recentChanges);
     }
     return this.fetchRecentChanges();
   }
 
-  _getChangeSuggestions(input: string) {
-    return this._getRecentChanges().then(changes =>
-      this._filterChanges(input, changes)
+  private getChangeSuggestions(input: string) {
+    return this.getRecentChanges().then(changes =>
+      this.filterChanges(input, changes)
     );
   }
 
-  _filterChanges(
+  filterChanges(
     input: string,
     changes: RebaseChange[]
   ): AutocompleteSuggestion[] {
@@ -152,16 +269,16 @@
       );
   }
 
-  _displayParentOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return hasParent && rebaseOnCurrent;
+  private displayParentOption() {
+    return this.hasParent && this.rebaseOnCurrent;
   }
 
-  _displayParentUpToDateMsg(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return hasParent && !rebaseOnCurrent;
+  private displayParentUpToDateMsg() {
+    return this.hasParent && !this.rebaseOnCurrent;
   }
 
-  _displayTipOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return !(!rebaseOnCurrent && !hasParent);
+  private displayTipOption() {
+    return this.rebaseOnCurrent || this.hasParent;
   }
 
   /**
@@ -171,63 +288,62 @@
    * rebased on top of the target branch. Leaving out the base implies that it
    * should be rebased on top of its current parent.
    */
-  _getSelectedBase() {
-    if (this.$.rebaseOnParentInput.checked) {
+  getSelectedBase() {
+    if (this.rebaseOnParentInput.checked) {
       return null;
     }
-    if (this.$.rebaseOnTipInput.checked) {
+    if (this.rebaseOnTipInput.checked) {
       return '';
     }
-    if (!this._text) {
+    if (!this.text) {
       return '';
     }
     // Change numbers will have their description appended by the
     // autocomplete.
-    return this._text.split(':')[0];
+    return this.text.split(':')[0];
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     const detail: ConfirmRebaseEventDetail = {
-      base: this._getSelectedBase(),
+      base: this.getSelectedBase(),
     };
     this.dispatchEvent(new CustomEvent('confirm', {detail}));
-    this._text = '';
+    this.text = '';
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('cancel'));
-    this._text = '';
+    this.text = '';
   }
 
-  _handleRebaseOnOther() {
-    this.$.parentInput.focus();
+  private handleRebaseOnOther() {
+    this.parentInput.focus();
   }
 
-  _handleEnterChangeNumberClick() {
-    this.$.rebaseOnOtherInput.checked = true;
+  private handleEnterChangeNumberClick() {
+    this.rebaseOnOtherInput.checked = true;
   }
 
   /**
    * Sets the default radio button based on the state of the app and
    * the corresponding value to be submitted.
    */
-  @observe('rebaseOnCurrent', 'hasParent')
-  _updateSelectedOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    // Polymer 2: check for undefined
+  private updateSelectedOption() {
+    const {rebaseOnCurrent, hasParent} = this;
     if (rebaseOnCurrent === undefined || hasParent === undefined) {
       return;
     }
 
-    if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
-      this.$.rebaseOnParentInput.checked = true;
-    } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
-      this.$.rebaseOnTipInput.checked = true;
+    if (this.displayParentOption()) {
+      this.rebaseOnParentInput.checked = true;
+    } else if (this.displayTipOption()) {
+      this.rebaseOnTipInput.checked = true;
     } else {
-      this.$.rebaseOnOtherInput.checked = true;
+      this.rebaseOnOtherInput.checked = true;
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
deleted file mode 100644
index 1052201..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .message {
-      font-style: italic;
-    }
-    .parentRevisionContainer label,
-    .parentRevisionContainer input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .parentRevisionContainer label {
-      margin-bottom: var(--spacing-xs);
-    }
-    .rebaseOption {
-      margin: var(--spacing-m) 0;
-    }
-  </style>
-  <gr-dialog
-    id="confirmDialog"
-    confirm-label="Rebase"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Confirm rebase</div>
-    <div class="main" slot="main">
-      <div
-        id="rebaseOnParent"
-        class="rebaseOption"
-        hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
-        <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
-          Rebase on parent change
-        </label>
-      </div>
-      <div
-        id="parentUpToDateMsg"
-        class="message"
-        hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]"
-      >
-        This change is up to date with its parent.
-      </div>
-      <div
-        id="rebaseOnTip"
-        class="rebaseOption"
-        hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input
-          id="rebaseOnTipInput"
-          name="rebaseOptions"
-          type="radio"
-          disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-        />
-        <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
-          Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
-            (breaks relation chain)
-          </span>
-        </label>
-      </div>
-      <div
-        id="tipUpToDateMsg"
-        class="message"
-        hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        Change is up to date with the target branch already ([[branch]])
-      </div>
-      <div id="rebaseOnOther" class="rebaseOption">
-        <input
-          id="rebaseOnOtherInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnOther"
-        />
-        <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-          Rebase on a specific change, ref, or commit
-          <span hidden$="[[!hasParent]]"> (breaks relation chain) </span>
-        </label>
-      </div>
-      <div class="parentRevisionContainer">
-        <gr-autocomplete
-          id="parentInput"
-          query="[[_query]]"
-          no-debounce=""
-          text="{{_text}}"
-          on-click="_handleEnterChangeNumberClick"
-          allow-non-suggested-values=""
-          placeholder="Change number, ref, or commit hash"
-        >
-        </gr-autocomplete>
-      </div>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 74c1b3c..bc000af 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -18,95 +18,203 @@
 import '../../../test/common-test-setup-karma';
 import './gr-confirm-rebase-dialog';
 import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi, waitUntil} from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {NumericChangeId} from '../../../types/common';
+import {NumericChangeId, BranchName} from '../../../types/common';
 import {createChangeViewChange} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-confirm-rebase-dialog tests', () => {
   let element: GrConfirmRebaseDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>`
+    );
   });
 
-  test('controls with parent and rebase on current available', () => {
+  test('render', async () => {
+    element.branch = 'test' as BranchName;
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `<gr-dialog
+      confirm-label="Rebase"
+      id="confirmDialog"
+      role="dialog"
+    >
+      <div class="header" slot="header">Confirm rebase</div>
+      <div class="main" slot="main">
+        <div class="rebaseOption" hidden="" id="rebaseOnParent">
+          <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
+          <label for="rebaseOnParentInput" id="rebaseOnParentLabel">
+            Rebase on parent change
+          </label>
+        </div>
+        <div class="message" hidden="" id="parentUpToDateMsg">
+          This change is up to date with its parent.
+        </div>
+        <div class="rebaseOption" hidden="" id="rebaseOnTip">
+          <input
+            disabled=""
+            id="rebaseOnTipInput"
+            name="rebaseOptions"
+            type="radio"
+          />
+          <label for="rebaseOnTipInput" id="rebaseOnTipLabel">
+            Rebase on top of the test branch
+            <span hidden=""> (breaks relation chain) </span>
+          </label>
+        </div>
+        <div class="message" id="tipUpToDateMsg">
+          Change is up to date with the target branch already (test)
+        </div>
+        <div class="rebaseOption" id="rebaseOnOther">
+          <input id="rebaseOnOtherInput" name="rebaseOptions" type="radio" />
+          <label for="rebaseOnOtherInput" id="rebaseOnOtherLabel">
+            Rebase on a specific change, ref, or commit
+            <span hidden=""> (breaks relation chain) </span>
+          </label>
+        </div>
+        <div class="parentRevisionContainer">
+          <gr-autocomplete
+            allow-non-suggested-values=""
+            id="parentInput"
+            no-debounce=""
+            placeholder="Change number, ref, or commit hash"
+          >
+          </gr-autocomplete>
+        </div>
+      </div>
+    </gr-dialog> `);
+  });
+
+  test('controls with parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = true;
-    flush();
-    assert.isTrue(element.$.rebaseOnParentInput.checked);
-    assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls with parent rebase on current not available', () => {
+  test('controls with parent rebase on current not available', async () => {
     element.rebaseOnCurrent = false;
     element.hasParent = true;
-    flush();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls without parent and rebase on current available', () => {
+  test('controls without parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = false;
-    flush();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls without parent rebase on current not available', () => {
+  test('controls without parent rebase on current not available', async () => {
     element.rebaseOnCurrent = false;
     element.hasParent = false;
-    flush();
-    assert.isTrue(element.$.rebaseOnOtherInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(element.rebaseOnOtherInput.checked);
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('input cleared on cancel or submit', () => {
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
+  test('input cleared on cancel or submit', async () => {
+    element.text = '123';
+    await element.updateComplete;
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
       new CustomEvent('confirm', {
         composed: true,
         bubbles: true,
       })
     );
-    assert.equal(element._text, '');
+    assert.equal(element.text, '');
 
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
+    element.text = '123';
+    await element.updateComplete;
+
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
         bubbles: true,
       })
     );
-    assert.equal(element._text, '');
+    assert.equal(element.text, '');
   });
 
-  test('_getSelectedBase', () => {
-    element._text = '5fab321c';
-    element.$.rebaseOnParentInput.checked = true;
-    assert.equal(element._getSelectedBase(), null);
-    element.$.rebaseOnParentInput.checked = false;
-    element.$.rebaseOnTipInput.checked = true;
-    assert.equal(element._getSelectedBase(), '');
-    element.$.rebaseOnTipInput.checked = false;
-    assert.equal(element._getSelectedBase(), element._text);
-    element._text = '101: Test';
-    assert.equal(element._getSelectedBase(), '101');
+  test('_getSelectedBase', async () => {
+    element.text = '5fab321c';
+    await element.updateComplete;
+
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked =
+      true;
+    assert.equal(element.getSelectedBase(), null);
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked =
+      false;
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked =
+      true;
+    assert.equal(element.getSelectedBase(), '');
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked =
+      false;
+    assert.equal(element.getSelectedBase(), element.text);
+    element.text = '101: Test';
+    await element.updateComplete;
+
+    assert.equal(element.getSelectedBase(), '101');
   });
 
   suite('parent suggestions', () => {
@@ -149,47 +257,52 @@
       );
     });
 
-    test('_getRecentChanges', () => {
-      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
-      return element
-        ._getRecentChanges()
-        .then(() => {
-          assert.deepEqual(element._recentChanges, recentChanges);
-          assert.equal(getChangesStub.callCount, 1);
-          // When called a second time, should not re-request recent changes.
-          element._getRecentChanges();
-        })
-        .then(() => {
-          assert.equal(recentChangesSpy.callCount, 2);
-          assert.equal(getChangesStub.callCount, 1);
-        });
+    test('_getRecentChanges', async () => {
+      const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
+      await element.getRecentChanges();
+      await element.updateComplete;
+
+      assert.deepEqual(element.recentChanges, recentChanges);
+      assert.equal(getChangesStub.callCount, 1);
+
+      // When called a second time, should not re-request recent changes.
+      await element.getRecentChanges();
+      await element.updateComplete;
+
+      assert.equal(recentChangesSpy.callCount, 2);
+      assert.equal(getChangesStub.callCount, 1);
     });
 
-    test('_filterChanges', () => {
-      assert.equal(element._filterChanges('123', recentChanges).length, 1);
-      assert.equal(element._filterChanges('12', recentChanges).length, 2);
-      assert.equal(element._filterChanges('awesome', recentChanges).length, 3);
-      assert.equal(element._filterChanges('third', recentChanges).length, 1);
+    test('_filterChanges', async () => {
+      assert.equal(element.filterChanges('123', recentChanges).length, 1);
+      assert.equal(element.filterChanges('12', recentChanges).length, 2);
+      assert.equal(element.filterChanges('awesome', recentChanges).length, 3);
+      assert.equal(element.filterChanges('third', recentChanges).length, 1);
 
       element.changeNumber = 123 as NumericChangeId;
-      assert.equal(element._filterChanges('123', recentChanges).length, 0);
-      assert.equal(element._filterChanges('124', recentChanges).length, 1);
-      assert.equal(element._filterChanges('awesome', recentChanges).length, 2);
+      await element.updateComplete;
+
+      assert.equal(element.filterChanges('123', recentChanges).length, 0);
+      assert.equal(element.filterChanges('124', recentChanges).length, 1);
+      assert.equal(element.filterChanges('awesome', recentChanges).length, 2);
     });
 
-    test('input text change triggers function', () => {
-      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
-      element.$.parentInput.noDebounce = true;
+    test('input text change triggers function', async () => {
+      const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
+      element.parentInput.noDebounce = true;
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.parentInput.$.input,
+        queryAndAssert(queryAndAssert(element, '#parentInput'), '#input'),
         13,
         null,
         'enter'
       );
-      element._text = '1';
-      assert.isTrue(recentChangesSpy.calledOnce);
-      element._text = '12';
-      assert.isTrue(recentChangesSpy.calledTwice);
+      await element.updateComplete;
+      element.text = '1';
+
+      await waitUntil(() => recentChangesSpy.calledOnce);
+      element.text = '12';
+
+      await waitUntil(() => recentChangesSpy.calledTwice);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index e0532c2..8ead6a0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -15,14 +15,15 @@
  * limitations under the License.
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-revert-dialog_html';
-import {customElement, property} from '@polymer/decorators';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators';
 import {ChangeInfo, CommitId} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -39,77 +40,173 @@
   message?: string;
 }
 
-@customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export interface CancelRevertEventDetail {
+  revertType: RevertType;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    /** Fired when the confirm button is pressed. */
+    // prettier-ignore
+    'confirm': CustomEvent<ConfirmRevertEventDetail>;
+    /** Fired when the cancel button is pressed. */
+    // prettier-ignore
+    'cancel': CustomEvent<CancelRevertEventDetail>;
   }
+}
 
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
+@customElement('gr-confirm-revert-dialog')
+export class GrConfirmRevertDialog extends LitElement {
   /* The revert message updated by the user
       The default value is set by the dialog */
-  @property({type: String})
-  _message = '';
+  @state()
+  message = '';
 
-  @property({type: Number})
-  _revertType = RevertType.REVERT_SINGLE_CHANGE;
+  @state()
+  private revertType = RevertType.REVERT_SINGLE_CHANGE;
 
-  @property({type: Boolean})
-  _showRevertSubmission = false;
+  @state()
+  private showRevertSubmission = false;
 
-  @property({type: Number})
-  _changesCount?: number;
+  @state()
+  private changesCount?: number;
 
-  @property({type: Boolean})
-  _showErrorMessage = false;
+  @state()
+  showErrorMessage = false;
 
   /* store the default revert messages per revert type so that we can
   check if user has edited the revert message or not
   Set when populate() is called */
-  @property({type: Array})
-  _originalRevertMessages: string[] = [];
+  @state()
+  private originalRevertMessages: string[] = [];
 
   // Store the actual messages that the user has edited
-  @property({type: Array})
-  _revertMessages: string[] = [];
+  @state()
+  private revertMessages: string[] = [];
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      .revertSubmissionLayout {
+        display: flex;
+        align-items: center;
+      }
+      .label {
+        margin-left: var(--spacing-m);
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        width: 73ch; /* Add a char to account for the border. */
+      }
+      .error {
+        color: var(--error-text-color);
+        margin-bottom: var(--spacing-m);
+      }
+      label[for='messageInput'] {
+        margin-top: var(--spacing-m);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        .confirmLabel=${'Revert'}
+        @confirm=${(e: Event) => this.handleConfirmTap(e)}
+        @cancel=${(e: Event) => this.handleCancelTap(e)}
+      >
+        <div class="header" slot="header">Revert Merged Change</div>
+        <div class="main" slot="main">
+          <div class="error" ?hidden=${!this.showErrorMessage}>
+            <span> A reason is required </span>
+          </div>
+          ${this.showRevertSubmission
+            ? html`
+                <div class="revertSubmissionLayout">
+                  <input
+                    name="revertOptions"
+                    type="radio"
+                    id="revertSingleChange"
+                    @change=${() => this.handleRevertSingleChangeClicked()}
+                    ?checked=${this.computeIfSingleRevert()}
+                  />
+                  <label
+                    for="revertSingleChange"
+                    class="label revertSingleChange"
+                  >
+                    Revert single change
+                  </label>
+                </div>
+                <div class="revertSubmissionLayout">
+                  <input
+                    name="revertOptions"
+                    type="radio"
+                    id="revertSubmission"
+                    @change=${() => this.handleRevertSubmissionClicked()}
+                    .checked=${this.computeIfRevertSubmission()}
+                  />
+                  <label for="revertSubmission" class="label revertSubmission">
+                    Revert entire submission (${this.changesCount} Changes)
+                  </label>
+                </div>
+              `
+            : nothing}
+          <gr-endpoint-decorator name="confirm-revert-change">
+            <label for="messageInput"> Revert Commit Message </label>
+            <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              .autocomplete=${'on'}
+              .maxRows=${15}
+              .bindValue=${this.message}
+              @bind-value-changed=${this.handleBindValueChanged}
+            ></iron-autogrow-textarea>
+          </gr-endpoint-decorator>
+        </div>
+      </gr-dialog>
+    `;
+  }
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  _computeIfSingleRevert(revertType: number) {
-    return revertType === RevertType.REVERT_SINGLE_CHANGE;
+  private computeIfSingleRevert() {
+    return this.revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
 
-  _computeIfRevertSubmission(revertType: number) {
-    return revertType === RevertType.REVERT_SUBMISSION;
+  private computeIfRevertSubmission() {
+    return this.revertType === RevertType.REVERT_SUBMISSION;
   }
 
-  _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+  modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
     return this.jsAPI.modifyRevertMsg(change, message, commitMessage);
   }
 
   populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
-    this._changesCount = changes.length;
+    this.changesCount = changes.length;
     // The option to revert a single change is always available
-    this._populateRevertSingleChangeMessage(
+    this.populateRevertSingleChangeMessage(
       change,
       commitMessage,
       change.current_revision
     );
-    this._populateRevertSubmissionMessage(change, changes, commitMessage);
+    this.populateRevertSubmissionMessage(change, changes, commitMessage);
   }
 
-  _populateRevertSingleChangeMessage(
+  populateRevertSingleChangeMessage(
     change: ChangeInfo,
     commitMessage: string,
     commitHash?: CommitId
@@ -127,20 +224,20 @@
       `${revertTitle}\n\n${revertCommitText}\n\n` +
       `Reason for revert: ${INSERT_REASON_STRING}\n`;
     // This is to give plugins a chance to update message
-    this._message = this._modifyRevertMsg(change, commitMessage, message);
-    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
-    this._showRevertSubmission = false;
-    this._revertMessages[this._revertType] = this._message;
-    this._originalRevertMessages[this._revertType] = this._message;
+    this.message = this.modifyRevertMsg(change, commitMessage, message);
+    this.revertType = RevertType.REVERT_SINGLE_CHANGE;
+    this.showRevertSubmission = false;
+    this.revertMessages[this.revertType] = this.message;
+    this.originalRevertMessages[this.revertType] = this.message;
   }
 
-  _getTrimmedChangeSubject(subject: string) {
+  private getTrimmedChangeSubject(subject: string) {
     if (!subject) return '';
     if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
     return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
   }
 
-  _modifyRevertSubmissionMsg(
+  private modifyRevertSubmissionMsg(
     change: ChangeInfo,
     msg: string,
     commitMessage: string
@@ -148,7 +245,7 @@
     return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
   }
 
-  _populateRevertSubmissionMessage(
+  populateRevertSubmissionMessage(
     change: ChangeInfo,
     changes: ChangeInfo[],
     commitMessage: string
@@ -170,68 +267,63 @@
     changes.forEach(change => {
       message +=
         `${change.change_id.substring(0, 10)}:` +
-        `${this._getTrimmedChangeSubject(change.subject)}\n`;
+        `${this.getTrimmedChangeSubject(change.subject)}\n`;
     });
-    this._message = this._modifyRevertSubmissionMsg(
+    this.message = this.modifyRevertSubmissionMsg(
       change,
       message,
       commitMessage
     );
-    this._revertType = RevertType.REVERT_SUBMISSION;
-    this._revertMessages[this._revertType] = this._message;
-    this._originalRevertMessages[this._revertType] = this._message;
-    this._showRevertSubmission = true;
+    this.revertType = RevertType.REVERT_SUBMISSION;
+    this.revertMessages[this.revertType] = this.message;
+    this.originalRevertMessages[this.revertType] = this.message;
+    this.showRevertSubmission = true;
   }
 
-  _handleRevertSingleChangeClicked() {
-    this._showErrorMessage = false;
-    if (this._message)
-      this._revertMessages[RevertType.REVERT_SUBMISSION] = this._message;
-    this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
-    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
+  private handleBindValueChanged(e: BindValueChangeEvent) {
+    this.message = e.detail.value;
   }
 
-  _handleRevertSubmissionClicked() {
-    this._showErrorMessage = false;
-    this._revertType = RevertType.REVERT_SUBMISSION;
-    if (this._message)
-      this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
-    this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
+  private handleRevertSingleChangeClicked() {
+    this.showErrorMessage = false;
+    if (this.message)
+      this.revertMessages[RevertType.REVERT_SUBMISSION] = this.message;
+    this.message = this.revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+    this.revertType = RevertType.REVERT_SINGLE_CHANGE;
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleRevertSubmissionClicked() {
+    this.showErrorMessage = false;
+    this.revertType = RevertType.REVERT_SUBMISSION;
+    if (this.message)
+      this.revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this.message;
+    this.message = this.revertMessages[RevertType.REVERT_SUBMISSION];
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     if (
-      this._message === this._originalRevertMessages[this._revertType] ||
-      this._message.includes(INSERT_REASON_STRING)
+      this.message === this.originalRevertMessages[this.revertType] ||
+      this.message.includes(INSERT_REASON_STRING)
     ) {
-      this._showErrorMessage = true;
+      this.showErrorMessage = true;
       return;
     }
     const detail: ConfirmRevertEventDetail = {
-      revertType: this._revertType,
-      message: this._message,
+      revertType: this.revertType,
+      message: this.message,
     };
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail,
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fire(this, 'confirm', detail);
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        detail: {revertType: this._revertType},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    const detail: ConfirmRevertEventDetail = {
+      revertType: this.revertType,
+    };
+    fire(this, 'cancel', detail);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
deleted file mode 100644
index b2acff2..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    .revertSubmissionLayout {
-      display: flex;
-      align-items: center;
-    }
-    .label {
-      margin-left: var(--spacing-m);
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-    .error {
-      color: var(--error-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    label[for='messageInput'] {
-      margin-top: var(--spacing-m);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Revert Merged Change</div>
-    <div class="main" slot="main">
-      <div class="error" hidden$="[[!_showErrorMessage]]">
-        <span> A reason is required </span>
-      </div>
-      <template is="dom-if" if="[[_showRevertSubmission]]">
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSingleChange"
-            on-change="_handleRevertSingleChangeClicked"
-            checked="[[_computeIfSingleRevert(_revertType)]]"
-          />
-          <label for="revertSingleChange" class="label revertSingleChange">
-            Revert single change
-          </label>
-        </div>
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSubmission"
-            on-change="_handleRevertSubmissionClicked"
-            checked="[[_computeIfRevertSubmission(_revertType)]]"
-          />
-          <label for="revertSubmission" class="label revertSubmission">
-            Revert entire submission ([[_changesCount]] Changes)
-          </label>
-        </div>
-      </template>
-      <gr-endpoint-decorator name="confirm-revert-change">
-        <label for="messageInput"> Revert Commit Message </label>
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          max-rows="15"
-          bind-value="{{_message}}"
-        ></iron-autogrow-textarea>
-      </gr-endpoint-decorator>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 38429b1..0fdcb97 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -15,26 +15,48 @@
  * limitations under the License.
  */
 
+import {fixture, html} from '@open-wc/testing-helpers';
 import '../../../test/common-test-setup-karma';
 import {createChange} from '../../../test/test-data-generators';
 import {CommitId} from '../../../types/common';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
 
-const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
-
 suite('gr-confirm-revert-dialog tests', () => {
   let element: GrConfirmRevertDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-revert-dialog></gr-confirm-revert-dialog>`
+    );
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-dialog role="dialog">
+        <div class="header" slot="header">Revert Merged Change</div>
+        <div class="main" slot="main">
+          <div class="error" hidden="">
+            <span> A reason is required </span>
+          </div>
+          <gr-endpoint-decorator name="confirm-revert-change">
+            <label for="messageInput"> Revert Commit Message </label>
+            <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              aria-disabled="false"
+            ></iron-autogrow-textarea>
+          </gr-endpoint-decorator>
+        </div>
+      </gr-dialog>
+    `);
   });
 
   test('no match', () => {
-    assert.isNotOk(element._message);
+    assert.isNotOk(element.message);
     const alertStub = sinon.stub();
     element.addEventListener('show-alert', alertStub);
-    element._populateRevertSingleChangeMessage(
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'not a commitHash in sight',
       undefined
@@ -43,8 +65,8 @@
   });
 
   test('single line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'one line commit\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -53,12 +75,12 @@
       'Revert "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('multi line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -67,12 +89,12 @@
       'Revert "many lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('issue above change id', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -81,12 +103,12 @@
       'Revert "much lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('revert a revert', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'Revert "one line commit"\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -95,6 +117,6 @@
       'Revert "Revert "one line commit""\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 8cb50fd..b8d4761 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -93,12 +93,16 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
@@ -129,7 +133,7 @@
       </p>
       <gr-thread-list
         id="commentList"
-        .threads="${this.unresolvedThreads}"
+        .threads=${this.unresolvedThreads}
         hide-dropdown
       >
       </gr-thread-list>
@@ -159,11 +163,11 @@
           ${this.renderChangeEdit()}
           <gr-endpoint-param
             name="change"
-            .value="${this.change}"
+            .value=${this.change}
           ></gr-endpoint-param>
           <gr-endpoint-param
             name="action"
-            .value="${this.action}"
+            .value=${this.action}
           ></gr-endpoint-param>
         </gr-endpoint-decorator>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 604dc51..c1fa7d5 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -178,10 +178,10 @@
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a id="download" .href="${this.computeDownloadLink()}" download>
+          <a id="download" .href=${this.computeDownloadLink()} download>
             ${this.computeDownloadFilename()}
           </a>
-          <a .href="${this.computeDownloadLink(true)}" download>
+          <a .href=${this.computeDownloadLink(true)} download>
             ${this.computeDownloadFilename(true)}
           </a>
         </div>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 4c5a3ee..142b999 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -186,7 +186,7 @@
         element,
         '.closeButtonContainer gr-button'
       );
-      tap(closeButton!);
+      tap(closeButton);
       await closeCalled;
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 56949fa..a711680 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
 import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import '../../diff/gr-patch-range-select/gr-patch-range-select';
 import '../../edit/gr-edit-controls/gr-edit-controls';
@@ -22,12 +21,10 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-commit-info/gr-commit-info';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-file-list-header_html';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {computeLatestPatchNum, PatchSet} from '../../../utils/patch-set-util';
-import {property, customElement} from '@polymer/decorators';
+import {property, customElement, query} from 'lit/decorators';
 import {
   AccountInfo,
   ChangeInfo,
@@ -47,27 +44,13 @@
   ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {getAppContext} from '../../../services/app-context';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-file-list-header': GrFileListHeader;
-  }
-}
-
-export interface GrFileListHeader {
-  $: {
-    modeSelect: GrDiffModeSelector;
-    expandBtn: GrButton;
-    collapseBtn: GrButton;
-  };
-}
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {when} from 'lit/directives/when';
+import {ifDefined} from 'lit/directives/if-defined';
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrFileListHeader extends LitElement {
   /**
    * @event expand-diffs
    */
@@ -112,7 +95,7 @@
   serverConfig?: ServerInfo;
 
   @property({type: Number})
-  shownFileCount?: number;
+  shownFileCount = 0;
 
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
@@ -126,25 +109,273 @@
   @property({type: String})
   filesExpanded?: FilesExpandedState;
 
-  // Caps the number of files that can be shown and have the 'show diffs' /
-  // 'hide diffs' buttons still be functional.
-  @property({type: Number})
-  readonly _maxFilesForBulkActions = 225;
-
   @property({type: Object})
   revisionInfo?: RevisionInfo;
 
+  @query('#modeSelect')
+  modeSelect?: GrDiffModeSelector;
+
+  @query('#expandBtn')
+  expandBtn?: GrButton;
+
+  @query('#collapseBtn')
+  collapseBtn?: GrButton;
+
   private readonly shortcuts = getAppContext().shortcutsService;
 
-  _expandAllDiffs() {
+  // Caps the number of files that can be shown and have the 'show diffs' /
+  // 'hide diffs' buttons still be functional.
+  private readonly maxFilesForBulkActions = 225;
+
+  static override styles = [
+    sharedStyles,
+    css`
+      .prefsButton {
+        float: right;
+      }
+      .patchInfoOldPatchSet.patchInfo-header {
+        background-color: var(--emphasis-color);
+      }
+      .patchInfo-header {
+        align-items: center;
+        display: flex;
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      .patchInfo-left {
+        align-items: baseline;
+        display: flex;
+      }
+      .patchInfoContent {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+      }
+      .patchInfo-header .container.latestPatchContainer {
+        display: none;
+      }
+      .patchInfoOldPatchSet .container.latestPatchContainer {
+        display: initial;
+      }
+      .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
+        display: none;
+      }
+      .latestPatchContainer a {
+        text-decoration: none;
+      }
+      .mobile {
+        display: none;
+      }
+      .patchInfo-header .container {
+        align-items: center;
+        display: flex;
+      }
+      .downloadContainer,
+      .uploadContainer {
+        margin-right: 16px;
+      }
+      .uploadContainer.hide {
+        display: none;
+      }
+      .rightControls {
+        align-self: flex-end;
+        margin: auto 0 auto auto;
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+        font-weight: var(--font-weight-normal);
+        justify-content: flex-end;
+      }
+      #collapseBtn,
+      .allExpanded #expandBtn,
+      .fileViewActions {
+        display: none;
+      }
+      .someExpanded #expandBtn {
+        margin-right: 8px;
+      }
+      .someExpanded #collapseBtn,
+      .allExpanded #collapseBtn,
+      .openFile .fileViewActions {
+        align-items: center;
+        display: flex;
+      }
+      .rightControls gr-button,
+      gr-patch-range-select {
+        margin: 0 -4px;
+      }
+      .fileViewActions gr-button {
+        margin: 0;
+        --gr-button-padding: 2px 4px;
+      }
+      .editMode .hideOnEdit {
+        display: none;
+      }
+      .showOnEdit {
+        display: none;
+      }
+      .editMode .showOnEdit {
+        display: initial;
+      }
+      .editMode .showOnEdit.flexContainer {
+        align-items: center;
+        display: flex;
+      }
+      .label {
+        font-weight: var(--font-weight-bold);
+        margin-right: 24px;
+      }
+      gr-commit-info,
+      gr-edit-controls {
+        margin-right: -5px;
+      }
+      .fileViewActionsLabel {
+        margin-right: var(--spacing-xs);
+      }
+      @media screen and (max-width: 50em) {
+        .patchInfo-header .desktop {
+          display: none;
+        }
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.change || !this.diffPrefs) {
+      return;
+    }
+    const editModeClass = this.computeEditModeClass(this.editMode);
+    const patchInfoClass = this.computePatchInfoClass(
+      this.patchNum,
+      this.allPatchSets
+    );
+    const expandedClass = this.computeExpandedClass(this.filesExpanded);
+    const prefsButtonHidden = this.computePrefsButtonHidden(
+      this.diffPrefs,
+      this.loggedIn
+    );
+    return html`
+      <div class="patchInfo-header ${editModeClass} ${patchInfoClass}">
+        <div class="patchInfo-left">
+          <div class="patchInfoContent">
+            <gr-patch-range-select
+              id="rangeSelect"
+              .changeNum=${this.changeNum}
+              .patchNum=${this.patchNum}
+              .basePatchNum=${this.basePatchNum}
+              .availablePatches=${this.allPatchSets}
+              .revisions=${this.change.revisions}
+              .revisionInfo=${this.revisionInfo}
+              @patch-range-change=${this.handlePatchChange}
+            >
+            </gr-patch-range-select>
+            <span class="separator"></span>
+            <gr-commit-info
+              .change=${this.change}
+              .serverConfig=${this.serverConfig}
+              .commitInfo=${this.commitInfo}
+            ></gr-commit-info>
+            <span class="container latestPatchContainer">
+              <span class="separator"></span>
+              <a href=${ifDefined(this.changeUrl)}>Go to latest patch set</a>
+            </span>
+          </div>
+        </div>
+        <div class="rightControls ${expandedClass}">
+          ${when(
+            this.editMode,
+            () => html`
+              <span class="showOnEdit flexContainer">
+                <gr-edit-controls
+                  id="editControls"
+                  .patchNum=${this.patchNum}
+                  .change=${this.change}
+                ></gr-edit-controls>
+                <span class="separator"></span>
+              </span>
+            `
+          )}
+          <div class="fileViewActions">
+            <span class="fileViewActionsLabel">Diff view:</span>
+            <gr-diff-mode-selector
+              id="modeSelect"
+              .saveOnChange=${this.loggedIn ?? false}
+            ></gr-diff-mode-selector>
+            <span
+              id="diffPrefsContainer"
+              class="hideOnEdit"
+              ?hidden=${prefsButtonHidden}
+            >
+              <gr-tooltip-content has-tooltip title="Diff preferences">
+                <gr-button
+                  link
+                  class="prefsButton desktop"
+                  @click=${this.handlePrefsTap}
+                  ><iron-icon icon="gr-icons:settings"></iron-icon
+                ></gr-button>
+              </gr-tooltip-content>
+            </span>
+            <span class="separator"></span>
+          </div>
+          <span class="downloadContainer desktop">
+            <gr-tooltip-content
+              has-tooltip
+              title=${this.createTitle(
+                Shortcut.OPEN_DOWNLOAD_DIALOG,
+                ShortcutSection.ACTIONS
+              )}
+            >
+              <gr-button link class="download" @click=${this.handleDownloadTap}
+                >Download</gr-button
+              >
+            </gr-tooltip-content>
+          </span>
+          ${when(
+            this.fileListActionsVisible(
+              this.shownFileCount,
+              this.maxFilesForBulkActions
+            ),
+            () => html` <gr-tooltip-content
+                has-tooltip
+                title=${this.createTitle(
+                  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+                  ShortcutSection.FILE_LIST
+                )}
+              >
+                <gr-button id="expandBtn" link @click=${this.expandAllDiffs}
+                  >Expand All</gr-button
+                >
+              </gr-tooltip-content>
+              <gr-tooltip-content
+                has-tooltip
+                title=${this.createTitle(
+                  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+                  ShortcutSection.FILE_LIST
+                )}
+              >
+                <gr-button id="collapseBtn" link @click=${this.collapseAllDiffs}
+                  >Collapse All</gr-button
+                >
+              </gr-tooltip-content>`,
+            () => html`
+              <div class="warning">
+                Bulk actions disabled because there are too many files.
+              </div>
+            `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private expandAllDiffs() {
     fireEvent(this, 'expand-diffs');
   }
 
-  _collapseAllDiffs() {
+  private collapseAllDiffs() {
     fireEvent(this, 'collapse-diffs');
   }
 
-  _computeExpandedClass(filesExpanded: FilesExpandedState) {
+  private computeExpandedClass(filesExpanded?: FilesExpandedState) {
     const classes = [];
     if (filesExpanded === FilesExpandedState.ALL) {
       classes.push('openFile');
@@ -156,18 +387,21 @@
     return classes.join(' ');
   }
 
-  _computePrefsButtonHidden(prefs: DiffPreferencesInfo, loggedIn: boolean) {
+  private computePrefsButtonHidden(
+    prefs: DiffPreferencesInfo,
+    loggedIn?: boolean
+  ) {
     return !loggedIn || !prefs;
   }
 
-  _fileListActionsVisible(
+  private fileListActionsVisible(
     shownFileCount: number,
     maxFilesForBulkActions: number
   ) {
     return shownFileCount <= maxFilesForBulkActions;
   }
 
-  _handlePatchChange(e: CustomEvent) {
+  handlePatchChange(e: CustomEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
       (basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
@@ -178,12 +412,12 @@
     GerritNav.navigateToChange(this.change, {patchNum, basePatchNum});
   }
 
-  _handlePrefsTap(e: Event) {
+  private handlePrefsTap(e: Event) {
     e.preventDefault();
     fireEvent(this, 'open-diff-prefs');
   }
 
-  _handleDownloadTap(e: Event) {
+  private handleDownloadTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -191,11 +425,11 @@
     );
   }
 
-  _computeEditModeClass(editMode?: boolean) {
+  private computeEditModeClass(editMode?: boolean) {
     return editMode ? 'editMode' : '';
   }
 
-  _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
+  computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
     const latestNum = computeLatestPatchNum(allPatchSets);
     if (patchNum === latestNum) {
       return '';
@@ -203,7 +437,13 @@
     return 'patchInfoOldPatchSet';
   }
 
-  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+  private createTitle(shortcutName: Shortcut, section: ShortcutSection) {
     return this.shortcuts.createTitle(shortcutName, section);
   }
 }
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list-header': GrFileListHeader;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
deleted file mode 100644
index fbba2fc..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .prefsButton {
-      float: right;
-    }
-    .patchInfoOldPatchSet.patchInfo-header {
-      background-color: var(--emphasis-color);
-    }
-    .patchInfo-header {
-      align-items: center;
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .patchInfo-left {
-      align-items: baseline;
-      display: flex;
-    }
-    .patchInfoContent {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-    .patchInfo-header .container.latestPatchContainer {
-      display: none;
-    }
-    .patchInfoOldPatchSet .container.latestPatchContainer {
-      display: initial;
-    }
-    .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
-      display: none;
-    }
-    .latestPatchContainer a {
-      text-decoration: none;
-    }
-    .mobile {
-      display: none;
-    }
-    .patchInfo-header .container {
-      align-items: center;
-      display: flex;
-    }
-    .downloadContainer,
-    .uploadContainer {
-      margin-right: 16px;
-    }
-    .uploadContainer.hide {
-      display: none;
-    }
-    .rightControls {
-      align-self: flex-end;
-      margin: auto 0 auto auto;
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-      font-weight: var(--font-weight-normal);
-      justify-content: flex-end;
-    }
-    #collapseBtn,
-    .allExpanded #expandBtn,
-    .fileViewActions {
-      display: none;
-    }
-    .someExpanded #expandBtn {
-      margin-right: 8px;
-    }
-    .someExpanded #collapseBtn,
-    .allExpanded #collapseBtn,
-    .openFile .fileViewActions {
-      align-items: center;
-      display: flex;
-    }
-    .rightControls gr-button,
-    gr-patch-range-select {
-      margin: 0 -4px;
-    }
-    .fileViewActions gr-button {
-      margin: 0;
-      --gr-button-padding: 2px 4px;
-    }
-    .editMode .hideOnEdit {
-      display: none;
-    }
-    .showOnEdit {
-      display: none;
-    }
-    .editMode .showOnEdit {
-      display: initial;
-    }
-    .editMode .showOnEdit.flexContainer {
-      align-items: center;
-      display: flex;
-    }
-    .label {
-      font-weight: var(--font-weight-bold);
-      margin-right: 24px;
-    }
-    gr-commit-info,
-    gr-edit-controls {
-      margin-right: -5px;
-    }
-    .fileViewActionsLabel {
-      margin-right: var(--spacing-xs);
-    }
-    @media screen and (max-width: 50em) {
-      .patchInfo-header .desktop {
-        display: none;
-      }
-    }
-  </style>
-  <div
-    class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]"
-  >
-    <div class="patchInfo-left">
-      <div class="patchInfoContent">
-        <gr-patch-range-select
-          id="rangeSelect"
-          change-num="[[changeNum]]"
-          patch-num="[[patchNum]]"
-          base-patch-num="[[basePatchNum]]"
-          available-patches="[[allPatchSets]]"
-          revisions="[[change.revisions]]"
-          revision-info="[[revisionInfo]]"
-          on-patch-range-change="_handlePatchChange"
-        >
-        </gr-patch-range-select>
-        <span class="separator"></span>
-        <gr-commit-info
-          change="[[change]]"
-          server-config="[[serverConfig]]"
-          commit-info="[[commitInfo]]"
-        ></gr-commit-info>
-        <span class="container latestPatchContainer">
-          <span class="separator"></span>
-          <a href$="[[changeUrl]]">Go to latest patch set</a>
-        </span>
-      </div>
-    </div>
-    <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
-      <template is="dom-if" if="[[editMode]]">
-        <span class="showOnEdit flexContainer">
-          <gr-edit-controls
-            id="editControls"
-            patch-num="[[patchNum]]"
-            change="[[change]]"
-          ></gr-edit-controls>
-          <span class="separator"></span>
-        </span>
-      </template>
-      <div class="fileViewActions">
-        <span class="fileViewActionsLabel">Diff view:</span>
-        <gr-diff-mode-selector
-          id="modeSelect"
-          save-on-change="[[loggedIn]]"
-        ></gr-diff-mode-selector>
-        <span
-          id="diffPrefsContainer"
-          class="hideOnEdit"
-          hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
-          hidden=""
-        >
-          <gr-tooltip-content has-tooltip title="Diff preferences">
-            <gr-button
-              link=""
-              class="prefsButton desktop"
-              on-click="_handlePrefsTap"
-              ><iron-icon icon="gr-icons:settings"></iron-icon
-            ></gr-button>
-          </gr-tooltip-content>
-        </span>
-        <span class="separator"></span>
-      </div>
-      <span class="downloadContainer desktop">
-        <gr-tooltip-content
-          has-tooltip
-          title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
-                   ShortcutSection.ACTIONS)]]"
-        >
-          <gr-button link="" class="download" on-click="_handleDownloadTap"
-            >Download</gr-button
-          >
-        </gr-tooltip-content>
-      </span>
-      <template
-        is="dom-if"
-        if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <gr-tooltip-content
-          has-tooltip
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-                  ShortcutSection.FILE_LIST)]]"
-        >
-          <gr-button id="expandBtn" link="" on-click="_expandAllDiffs"
-            >Expand All</gr-button
-          >
-        </gr-tooltip-content>
-        <gr-tooltip-content
-          has-tooltip
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-                  ShortcutSection.FILE_LIST)]]"
-        >
-          <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs"
-            >Collapse All</gr-button
-          >
-        </gr-tooltip-content>
-      </template>
-      <template
-        is="dom-if"
-        if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <div class="warning">
-          Bulk actions disabled because there are too many files.
-        </div>
-      </template>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
index 821c7c5..ac2b4d4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -19,11 +19,9 @@
 import './gr-file-list-header';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import 'lodash/lodash';
 import {createChange, createRevision} from '../../../test/test-data-generators';
 import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {GrFileListHeader} from './gr-file-list-header';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {
   BasePatchSetNum,
   ChangeId,
@@ -33,19 +31,33 @@
 import {ChangeInfo, ChangeStatus} from '../../../api/rest-api.js';
 import {PatchSet} from '../../../utils/patch-set-util';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-
-const basicFixture = fixtureFromElement('gr-file-list-header');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-file-list-header tests', () => {
   let element: GrFileListHeader;
+  const change: ChangeInfo = {
+    ...createChange(),
+    change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca' as ChangeId,
+    revisions: {
+      rev2: createRevision(2),
+      rev1: createRevision(1),
+      rev13: createRevision(13),
+      rev3: createRevision(3),
+    },
+    status: 'NEW' as ChangeStatus,
+    labels: {},
+  };
 
-  setup(() => {
-    stubRestApi('getAccount').returns(Promise.resolve(undefined));
-    element = basicFixture.instantiate();
-  });
-
-  teardown(async () => {
-    await flush();
+  setup(async () => {
+    stubRestApi('getAccount').resolves(undefined);
+    element = await fixture(
+      html`<gr-file-list-header
+        .change=${change}
+        .diffPrefs=${createDefaultDiffPrefs()}
+        .shownFileCount=${3}
+      ></gr-file-list-header>`
+    );
   });
 
   test('Diff preferences hidden when no prefs', async () => {
@@ -55,53 +67,63 @@
 
     element.diffPrefs = createDefaultDiffPrefs();
     element.loggedIn = true;
-    await flush();
+    await element.updateComplete;
+
     assert.isFalse(
       queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
     );
   });
 
   test('expandAllDiffs called when expand button clicked', async () => {
-    element.shownFileCount = 1;
-    await flush();
-    const expandAllDiffsStub = sinon.stub(element, '_expandAllDiffs');
-    MockInteractions.tap(queryAndAssert(element, '#expandBtn'));
-    assert.isTrue(expandAllDiffsStub.called);
+    const expandDiffsListener = sinon.stub();
+    element.addEventListener('expand-diffs', expandDiffsListener);
+
+    queryAndAssert<GrButton>(element, 'gr-button#expandBtn').click();
+    await element.updateComplete;
+
+    assert.isTrue(expandDiffsListener.called);
   });
 
   test('collapseAllDiffs called when collapse button clicked', async () => {
-    element.shownFileCount = 1;
-    await flush();
-    const collapseAllDiffsStub = sinon.stub(element, '_collapseAllDiffs');
-    MockInteractions.tap(queryAndAssert(element, '#collapseBtn'));
-    assert.isTrue(collapseAllDiffsStub.called);
+    const collapseAllDiffsListener = sinon.stub();
+    element.addEventListener('collapse-diffs', collapseAllDiffsListener);
+
+    queryAndAssert<GrButton>(element, 'gr-button#collapseBtn').click();
+    await element.updateComplete;
+
+    assert.isTrue(collapseAllDiffsListener.called);
   });
 
   test('show/hide diffs disabled for large amounts of files', async () => {
-    const computeSpy = sinon.spy(element, '_fileListActionsVisible');
     element.changeNum = 42 as NumericChangeId;
     element.basePatchNum = 'PARENT' as BasePatchSetNum;
     element.patchNum = '2' as PatchSetNum;
     element.shownFileCount = 1;
-    await flush();
-    assert.isTrue(computeSpy.lastCall.returnValue);
-    _.times(element._maxFilesForBulkActions + 1, () => {
-      element.shownFileCount = element.shownFileCount! + 1;
-    });
-    assert.isFalse(computeSpy.lastCall.returnValue);
+    await element.updateComplete;
+
+    queryAndAssert(element, 'gr-button#expandBtn');
+    queryAndAssert(element, 'gr-button#collapseBtn');
+    assert.isNotOk(query(element, '.warning'));
+
+    element.shownFileCount = 226; // more than element.maxFilesForBulkActions
+    await element.updateComplete;
+
+    assert.isNotOk(query(element, 'gr-button#expandBtn'));
+    assert.isNotOk(query(element, 'gr-button#collapseBtn'));
+    queryAndAssert(element, '.warning');
   });
 
   test('fileViewActions are properly hidden', async () => {
     const actions = queryAndAssert(element, '.fileViewActions');
     assert.equal(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.SOME;
-    await flush();
+    await element.updateComplete;
     assert.notEqual(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.ALL;
-    await flush();
+    await element.updateComplete;
     assert.notEqual(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.NONE;
-    await flush();
+    await element.updateComplete;
     assert.equal(getComputedStyle(actions).display, 'none');
   });
 
@@ -109,7 +131,7 @@
     // Only the expand button should be visible in the initial state when
     // NO files are expanded.
     element.shownFileCount = 10;
-    await flush();
+    await element.updateComplete;
     const expandBtn = queryAndAssert(element, '#expandBtn');
     const collapseBtn = queryAndAssert(element, '#collapseBtn');
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
@@ -118,46 +140,37 @@
     // Both expand and collapse buttons should be visible when SOME files are
     // expanded.
     element.filesExpanded = FilesExpandedState.SOME;
-    await flush();
+    await element.updateComplete;
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
 
     // Only the collapse button should be visible when ALL files are expanded.
     element.filesExpanded = FilesExpandedState.ALL;
-    await flush();
+    await element.updateComplete;
     assert.equal(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
 
     // Only the expand button should be visible when NO files are expanded.
     element.filesExpanded = FilesExpandedState.NONE;
-    await flush();
+    await element.updateComplete;
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
   });
 
-  test('navigateToChange called when range select changes', () => {
+  test('navigateToChange called when range select changes', async () => {
     const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element.change = {
-      ...createChange(),
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca' as ChangeId,
-      revisions: {
-        rev2: createRevision(2),
-        rev1: createRevision(1),
-        rev13: createRevision(13),
-        rev3: createRevision(3),
-      },
-      status: 'NEW' as ChangeStatus,
-      labels: {},
-    } as ChangeInfo;
     element.basePatchNum = 1 as BasePatchSetNum;
     element.patchNum = 2 as PatchSetNum;
+    await element.updateComplete;
 
-    element._handlePatchChange({
+    element.handlePatchChange({
       detail: {basePatchNum: 1, patchNum: 3},
     } as CustomEvent);
+    await element.updateComplete;
+
     assert.equal(navigateToChangeStub.callCount, 1);
     assert.isTrue(
-      navigateToChangeStub.lastCall.calledWithExactly(element.change, {
+      navigateToChangeStub.lastCall.calledWithExactly(change, {
         patchNum: 3 as PatchSetNum,
         basePatchNum: 1 as BasePatchSetNum,
       })
@@ -171,29 +184,29 @@
       {num: 1 as PatchSetNum, desc: undefined, sha: ''},
     ];
     assert.equal(
-      element._computePatchInfoClass(1 as PatchSetNum, allPatchSets),
+      element.computePatchInfoClass(1 as PatchSetNum, allPatchSets),
       'patchInfoOldPatchSet'
     );
     assert.equal(
-      element._computePatchInfoClass(2 as PatchSetNum, allPatchSets),
+      element.computePatchInfoClass(2 as PatchSetNum, allPatchSets),
       'patchInfoOldPatchSet'
     );
     assert.equal(
-      element._computePatchInfoClass(4 as PatchSetNum, allPatchSets),
+      element.computePatchInfoClass(4 as PatchSetNum, allPatchSets),
       ''
     );
   });
 
   suite('editMode behavior', () => {
-    setup(() => {
+    setup(async () => {
       element.loggedIn = true;
-      element.diffPrefs = createDefaultDiffPrefs();
+      await element.updateComplete;
     });
 
-    const isVisible = (el: HTMLElement) => {
+    function isVisible(el: HTMLElement) {
       assert.ok(el);
       return getComputedStyle(el).getPropertyValue('display') !== 'none';
-    };
+    }
 
     test('patch specific elements', async () => {
       element.editMode = true;
@@ -202,14 +215,14 @@
         {num: 2 as PatchSetNum, desc: undefined, sha: ''},
         {num: 3 as PatchSetNum, desc: undefined, sha: ''},
       ];
-      await flush();
+      await element.updateComplete;
 
       assert.isFalse(
         isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
       );
 
       element.editMode = false;
-      await flush();
+      await element.updateComplete;
 
       assert.isTrue(
         isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
@@ -218,27 +231,21 @@
 
     test('edit-controls visibility', async () => {
       element.editMode = false;
-      await flush();
-      // on the first render, when editMode is false, editControls are not
-      // in the DOM to reduce size of DOM and make first render faster.
-      assert.isUndefined(query(element, '#editControls'));
+      await element.updateComplete;
+
+      assert.isNotOk(query(element, '#editControls'));
 
       element.editMode = true;
-      await flush();
-      queryAndAssert<HTMLElement>(element, '#editControls').parentElement;
+      await element.updateComplete;
+
       assert.isTrue(
-        isVisible(
-          queryAndAssert<HTMLElement>(element, '#editControls').parentElement!
-        )
+        isVisible(queryAndAssert<HTMLElement>(element, '#editControls'))
       );
 
       element.editMode = false;
-      await flush();
-      assert.isFalse(
-        isVisible(
-          queryAndAssert<HTMLElement>(element, '#editControls').parentElement!
-        )
-      );
+      await element.updateComplete;
+
+      assert.isNotOk(query<HTMLElement>(element, '#editControls'));
     });
   });
 });
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 231b9d0..7625756 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
@@ -110,6 +110,7 @@
 interface ReviewedFileInfo extends FileInfo {
   isReviewed?: boolean;
 }
+
 export interface NormalizedFileInfo extends ReviewedFileInfo {
   __path: string;
 }
@@ -368,9 +369,11 @@
     ];
   }
 
-  private fileCursor = new GrCursorManager();
+  // private but used in test
+  fileCursor = new GrCursorManager();
 
-  private diffCursor = new GrDiffCursor();
+  // private but used in test
+  diffCursor = new GrDiffCursor();
 
   constructor() {
     super();
@@ -578,15 +581,8 @@
     }, createDefaultPatchChange());
   }
 
-  _getDiffPreferences() {
-    return this.restApiService.getDiffPreferences();
-  }
-
-  _getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  private _toggleFileExpanded(file: PatchSetFile) {
+  // private but used in test
+  _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(
@@ -728,7 +724,8 @@
     return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
-  private _reviewFile(path: string, reviewed?: boolean) {
+  // private but used in test
+  _reviewFile(path: string, reviewed?: boolean) {
     if (this.editMode) {
       return Promise.resolve();
     }
@@ -1331,7 +1328,8 @@
     this.diffCursor.reInitAndUpdateStops();
   }
 
-  private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+  // private but used in test
+  _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
     for (const diff of collapsedDiffs) {
       diff.cancel();
       diff.clearDiffContent();
@@ -1343,9 +1341,11 @@
    * for each path in order, awaiting the previous render to complete before
    * continuing.
    *
+   * private but used in test
+   *
    * @param initialCount The total number of paths in the pass.
    */
-  private async _renderInOrder(
+  async _renderInOrder(
     files: PatchSetFile[],
     diffElements: GrDiffHost[],
     initialCount: number
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
deleted file mode 100644
index 991c995..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ /dev/null
@@ -1,1702 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-file-list.js';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {runA11yAudit} from '../../../test/a11y-test-utils.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {
-  listenOnce,
-  mockPromise,
-  query,
-  spyRestApi,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-import {EditPatchSetNum} from '../../../types/common.js';
-import {createCommentThreads} from '../../../utils/comment-util.js';
-import {
-  createChange,
-  createChangeComments,
-  createCommit,
-  createParsedChange,
-  createRevision,
-} from '../../../test/test-data-generators.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {queryAndAssert} from '../../../utils/common-util.js';
-
-const commentApiMock = createCommentApiMockWithTemplateElement(
-    'gr-file-list-comment-api-mock', html`
-    <gr-file-list id="fileList"></gr-file-list>
-`);
-
-const basicFixture = fixtureFromElement(commentApiMock.is);
-
-suite('gr-diff a11y test', () => {
-  test('audit', async () => {
-    await runA11yAudit(basicFixture);
-  });
-});
-
-suite('gr-file-list tests', () => {
-  let element;
-  let commentApiWrapper;
-
-  let saveStub;
-
-  suite('basic tests', () => {
-    setup(async () => {
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
-      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
-        Promise.resolve('')
-      );
-      stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
-      stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
-
-      element._loading = false;
-      element.diffPrefs = {};
-      element.numFilesShown = 200;
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
-          () => Promise.resolve());
-    });
-
-    test('correct number of files are shown', () => {
-      element.fileListIncrement = 300;
-      element._filesByPath = Array(500).fill(0)
-          .reduce((_filesByPath, _, idx) => {
-            _filesByPath['/file' + idx] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-
-      flush();
-      assert.equal(
-          element.root.querySelectorAll('.file-row').length,
-          element.numFilesShown);
-      const controlRow = element.shadowRoot
-          .querySelector('.controlRow');
-      assert.isFalse(controlRow.classList.contains('invisible'));
-      assert.equal(element.$.incrementButton.textContent.trim(),
-          'Show 300 more');
-      assert.equal(element.$.showAllButton.textContent.trim(),
-          'Show all 500 files');
-
-      MockInteractions.tap(element.$.showAllButton);
-      flush();
-
-      assert.equal(element.numFilesShown, 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 = Array(10).fill(0)
-          .reduce((_filesByPath, _, idx) => {
-            _filesByPath['/file' + idx] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-      flush();
-      assert.equal(
-          element.root.querySelectorAll('.file-row').length, 10);
-      assert.equal(renderedStub.callCount, 10);
-    });
-
-    test('calculate totals for patch number', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with a commit message that isn't the first file.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with no commit message.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with files missing either lines_inserted or lines_deleted.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {lines_inserted: 1},
-        'myfile.txt': {lines_deleted: 1},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 1,
-        deleted: 1,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('binary only files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 0,
-        deleted: 0,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isTrue(element._hideChangeTotals);
-    });
-
-    test('binary and regular files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
-        'myfile2.txt': {lines_inserted: 10},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 10,
-        deleted: 5,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('_formatBytes function', () => {
-      const table = {
-        '64': '+64 B',
-        '1023': '+1023 B',
-        '1024': '+1 KiB',
-        '4096': '+4 KiB',
-        '1073741824': '+1 GiB',
-        '-64': '-64 B',
-        '-1023': '-1023 B',
-        '-1024': '-1 KiB',
-        '-4096': '-4 KiB',
-        '-1073741824': '-1 GiB',
-        '0': '+/-0 B',
-      };
-      for (const [bytes, expected] of Object.entries(table)) {
-        assert.equal(element._formatBytes(Number(bytes)), expected);
-      }
-    });
-
-    test('_formatPercentage function', () => {
-      const table = [
-        {size: 100,
-          delta: 100,
-          display: '',
-        },
-        {size: 195060,
-          delta: 64,
-          display: '(+0%)',
-        },
-        {size: 195060,
-          delta: -64,
-          display: '(-0%)',
-        },
-        {size: 394892,
-          delta: -7128,
-          display: '(-2%)',
-        },
-        {size: 90,
-          delta: -10,
-          display: '(-10%)',
-        },
-        {size: 110,
-          delta: 10,
-          display: '(+10%)',
-        },
-      ];
-
-      for (const item of table) {
-        assert.equal(element._formatPercentage(
-            item.size, item.delta), item.display);
-      }
-    });
-
-    test('comment filtering', () => {
-      element.changeComments = createChangeComments();
-      const parentTo1 = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-
-      const parentTo2 = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-
-      const _1To2 = {
-        basePatchNum: 1,
-        patchNum: 2,
-      };
-
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo1
-              , {__path: '/COMMIT_MSG'}), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2
-              , {__path: '/COMMIT_MSG'}), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: 'unresolved.file'}), '1 draft');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: 'unresolved.file'}), '1 draft');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              {__path: 'unresolved.file'}), '1d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'unresolved.file'}), '1d');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              {__path: 'myfile.txt'}
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              {__path: 'file_added_in_rev2.txt'}
-          ), '');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              {__path: '/COMMIT_MSG'}
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: '/COMMIT_MSG'}), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: '/COMMIT_MSG'}), '2 drafts');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: '/COMMIT_MSG'}), '2 drafts');
-      assert.equal(
-          element._computeDraftsStringMobile(
-              element.changeComments,
-              parentTo1,
-              {__path: '/COMMIT_MSG'}
-          ), '2d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: '/COMMIT_MSG'}), '2d');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              {__path: 'myfile.txt'}
-          ), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '3c');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo2,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '');
-    });
-
-    test('_reviewedTitle', () => {
-      assert.equal(
-          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
-
-      assert.equal(
-          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
-    });
-
-    suite('keyboard shortcuts', () => {
-      setup(() => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-          'file_added_in_rev2.txt': {},
-          'myfile.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 2,
-        };
-        element.change = {_number: 42};
-        element.fileCursor.setCursorAtIndex(0);
-      });
-
-      test('toggle left diff via shortcut', () => {
-        const toggleLeftDiffStub = sinon.stub();
-        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
-        // https://github.com/sinonjs/sinon/issues/781
-        const diffsStub = sinon.stub(element, 'diffs')
-            .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
-        assert.isTrue(toggleLeftDiffStub.calledOnce);
-        diffsStub.restore();
-      });
-
-      test('keyboard shortcuts', () => {
-        flush();
-
-        const items = [...element.root.querySelectorAll('.file-row')];
-        element.fileCursor.stops = items;
-        element.fileCursor.setCursorAtIndex(0);
-        assert.equal(items.length, 3);
-        assert.isTrue(items[0].classList.contains('selected'));
-        assert.isFalse(items[1].classList.contains('selected'));
-        assert.isFalse(items[2].classList.contains('selected'));
-        // j with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'J');
-        assert.equal(element.fileCursor.index, 0);
-        // down should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
-        assert.equal(element.fileCursor.index, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-
-        const navStub = sinon.stub(GerritNav, 'navigateToDiff');
-        assert.equal(element.fileCursor.index, 2);
-        assert.equal(element.selectedIndex, 2);
-
-        // k with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'K');
-        assert.equal(element.fileCursor.index, 2);
-
-        // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
-        assert.equal(element.fileCursor.index, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
-
-        assert(navStub.lastCall.calledWith(element.change,
-            'file_added_in_rev2.txt', 2),
-        'Should navigate to /c/42/2/file_added_in_rev2.txt');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-
-        const createCommentInPlaceStub = sinon.stub(element.diffCursor,
-            'createCommentInPlace');
-        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
-        assert.isTrue(createCommentInPlaceStub.called);
-      });
-
-      test('i key shows/hides selected inline diff', () => {
-        const paths = Object.keys(element._filesByPath);
-        sinon.stub(element, '_expandedFilesChanged');
-        flush();
-        const files = [...element.root.querySelectorAll('.file-row')];
-        element.fileCursor.stops = files;
-        element.fileCursor.setCursorAtIndex(0);
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
-        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]);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-
-        element.fileCursor.setCursorAtIndex(1);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
-        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]);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
-        assert.equal(element.diffs.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));
-        }
-        // since _expandedFilesChanged is stubbed
-        element.filesExpanded = FilesExpandedState.ALL;
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-      });
-
-      test('r key toggles reviewed flag', () => {
-        const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
-        const getNumReviewed = () => element._files.reduce(reducer, 0);
-        flush();
-
-        // Default state should be unreviewed.
-        assert.equal(getNumReviewed(), 0);
-
-        // Press the review key to toggle it (set the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        flush();
-        assert.equal(getNumReviewed(), 1);
-
-        // Press the review key to toggle it (clear the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.equal(getNumReviewed(), 0);
-      });
-
-      suite('handleOpenFile', () => {
-        let interact;
-
-        setup(() => {
-          const openCursorStub = sinon.stub(element, '_openCursorFile');
-          const openSelectedStub = sinon.stub(element, '_openSelectedFile');
-          const expandStub = sinon.stub(element, '_toggleFileExpanded');
-
-          interact = function() {
-            openCursorStub.reset();
-            openSelectedStub.reset();
-            expandStub.reset();
-            element.handleOpenFile();
-            const result = {};
-            if (openCursorStub.called) {
-              result.opened_cursor = true;
-            }
-            if (openSelectedStub.called) {
-              result.opened_selected = true;
-            }
-            if (expandStub.called) {
-              result.expanded = true;
-            }
-            return result;
-          };
-        });
-
-        test('open from selected file', () => {
-          element.filesExpanded = FilesExpandedState.NONE;
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-
-        test('open from diff cursor', () => {
-          element.filesExpanded = FilesExpandedState.ALL;
-          assert.deepEqual(interact(), {opened_cursor: true});
-        });
-
-        test('expand when user prefers', () => {
-          element.filesExpanded = FilesExpandedState.NONE;
-          assert.deepEqual(interact(), {opened_selected: true});
-          element._userPrefs = {};
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-      });
-
-      test('shift+left/shift+right', () => {
-        const moveLeftStub = sinon.stub(element.diffCursor, 'moveLeft');
-        const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
-
-        let noDiffsExpanded = true;
-        sinon.stub(element, '_noDiffsExpanded')
-            .callsFake(() => noDiffsExpanded);
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowLeft');
-        assert.isFalse(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowRight');
-        assert.isFalse(moveRightStub.called);
-
-        noDiffsExpanded = false;
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowLeft');
-        assert.isTrue(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowRight');
-        assert.isTrue(moveRightStub.called);
-      });
-    });
-
-    test('file review status', () => {
-      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'file_added_in_rev2.txt': {},
-        'myfile.txt': {},
-      };
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element.fileCursor.setCursorAtIndex(0);
-      const reviewSpy = sinon.spy(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      flush();
-      const fileRows =
-          element.root.querySelectorAll('.row:not(.header-row)');
-      const checkSelector = 'span.reviewedSwitch[role="switch"]';
-      const commitMsg = fileRows[0].querySelector(checkSelector);
-      const fileAdded = fileRows[1].querySelector(checkSelector);
-      const myFile = fileRows[2].querySelector(checkSelector);
-
-      assert.equal(commitMsg.getAttribute('aria-checked'), 'true');
-      assert.equal(fileAdded.getAttribute('aria-checked'), 'false');
-      assert.equal(myFile.getAttribute('aria-checked'), 'true');
-
-      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
-      const markReviewLabel = fileRows[0].querySelector('.markReviewed');
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-
-      const clickSpy = sinon.spy(element, '_reviewedClick');
-      MockInteractions.tap(markReviewLabel);
-      // assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
-      // assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-      assert.isTrue(reviewSpy.calledOnce);
-
-      MockInteractions.tap(markReviewLabel);
-      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-      assert.isTrue(reviewSpy.calledTwice);
-
-      assert.isFalse(toggleExpandSpy.called);
-    });
-
-    test('_handleFileListClick', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      const row = dom(element.root)
-          .querySelector(`.row[data-file='{"path":"f1.txt"}']`);
-
-      // Click on the expand button, resulting in _toggleFileExpanded being
-      // called and not resulting in a call to _reviewFile.
-      row.querySelector('div.show-hide').click();
-      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.
-      element.root.querySelector('gr-diff-host')
-          .click();
-      assert.isTrue(clickSpy.calledTwice);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
-    });
-
-    test('_handleFileListClick editMode', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element.editMode = true;
-      flush();
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      // Tap the edit controls. Should be ignored by _handleFileListClick.
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.editFileControls'));
-      assert.isTrue(clickSpy.calledOnce);
-      assert.isFalse(toggleExpandSpy.called);
-    });
-
-    test('checkbox shows/hides diff inline', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element.fileCursor.setCursorAtIndex(0);
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
-      const fileRows =
-          element.root.querySelectorAll('.row:not(.header-row)');
-      // Because the label surrounds the input, the tap event is triggered
-      // there first.
-      const showHideCheck = fileRows[0].querySelector(
-          'span.show-hide[role="switch"]');
-      const showHideLabel = showHideCheck.querySelector('.show-hide-icon');
-      assert.equal(showHideCheck.getAttribute('aria-checked'), 'false');
-      MockInteractions.tap(showHideLabel);
-      assert.equal(showHideCheck.getAttribute('aria-checked'), 'true');
-      assert.notEqual(
-          element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
-          -1);
-    });
-
-    test('diff mode correctly toggles the diffs', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      sinon.spy(element, '_updateDiffPreferences');
-      element.fileCursor.setCursorAtIndex(0);
-      flush();
-
-      // Tap on a file to generate the diff.
-      const row = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) span.show-hide')[0];
-
-      MockInteractions.tap(row);
-      flush();
-      element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
-      element.set('diffViewMode', 'UNIFIED_DIFF');
-      assert.isTrue(element._updateDiffPreferences.called);
-    });
-
-    test('expanded attribute not set on path when not expanded', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-    });
-
-    test('tapping row ignores links', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
-      const commitMsgFile = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) a.pathLink')[0];
-
-      // Remove href attribute so the app doesn't route to a diff view
-      commitMsgFile.removeAttribute('href');
-      const togglePathSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      MockInteractions.tap(commitMsgFile);
-      flush();
-      assert(togglePathSpy.notCalled, 'file is opened as diff view');
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.show-hide')).display,
-      'none');
-    });
-
-    test('_toggleFileExpanded', () => {
-      const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {}};
-      const renderSpy = sinon.spy(element, '_renderInOrder');
-      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
-
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(element._expandedFiles.length, 0);
-      element._toggleFileExpanded({path});
-      flush();
-      assert.equal(collapseStub.lastCall.args[0].length, 0);
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
-
-      assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element._expandedFiles.some(f => f.path === path));
-      element._toggleFileExpanded({path});
-      flush();
-
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(renderSpy.callCount, 1);
-      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');
-      const reInitStub = sinon.stub(element.diffCursor,
-          'reInitAndUpdateStops');
-
-      const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {}};
-      element.expandAllDiffs();
-      flush();
-      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-      assert.isTrue(reInitStub.calledOnce);
-      assert.equal(collapseStub.lastCall.args[0].length, 0);
-
-      element.collapseAllDiffs();
-      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');
-      const path = 'path/to/my/file.txt';
-      const promise = mockPromise();
-      const diffs = [{
-        path,
-        style: {},
-        reload() {
-          promise.resolve();
-        },
-        prefetchDiff() {},
-        cancel() {},
-        getCursorStops() { return []; },
-        addEventListener(eventName, callback) {
-          if (['render-start', 'render-content', 'scroll']
-              .indexOf(eventName) >= 0) {
-            callback(new Event(eventName));
-          }
-        },
-      }];
-      sinon.stub(element, 'diffs').get(() => diffs);
-      element.push('_expandedFiles', {path});
-      await promise;
-    });
-
-    test('_clearCollapsedDiffs', () => {
-      const diff = {
-        cancel: sinon.stub(),
-        clearDiffContent: sinon.stub(),
-      };
-      element._clearCollapsedDiffs([diff]);
-      assert.isTrue(diff.cancel.calledOnce);
-      assert.isTrue(diff.clearDiffContent.calledOnce);
-    });
-
-    test('filesExpanded value updates to correct enum', () => {
-      element._filesByPath = {
-        'foo.bar': {},
-        'baz.bar': {},
-      };
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.NONE);
-      element.push('_expandedFiles', {path: 'baz.bar'});
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.SOME);
-      element.push('_expandedFiles', {path: 'foo.bar'});
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.ALL);
-      element.collapseAllDiffs();
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.NONE);
-      element.expandAllDiffs();
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.ALL);
-    });
-
-    test('_renderInOrder', async () => {
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      let callCount = 0;
-      const diffs = [{
-        path: 'p0',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(callCount++, 2);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p1',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(callCount++, 1);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p2',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3);
-      await flush();
-      assert.isFalse(reviewStub.called);
-    });
-
-    test('_renderInOrder logged in', async () => {
-      element._loggedIn = true;
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      let callCount = 0;
-      const diffs = [{
-        path: 'p2',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(reviewStub.callCount, 0);
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([{path: 'p2'}], diffs, 1);
-      await flush();
-      assert.equal(reviewStub.callCount, 1);
-    });
-
-    test('_renderInOrder respects diffPrefs.manual_review', async () => {
-      element._loggedIn = true;
-      element.diffPrefs = {manual_review: true};
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      const diffs = [{
-        path: 'p',
-        style: {},
-        prefetchDiff() {},
-        reload() { return Promise.resolve(); },
-      }];
-
-      element._renderInOrder([{path: 'p'}], diffs, 1);
-      await flush();
-      assert.isFalse(reviewStub.called);
-      delete element.diffPrefs.manual_review;
-      element._renderInOrder([{path: 'p'}], diffs, 1);
-      await flush();
-      assert.isTrue(reviewStub.called);
-      assert.isTrue(reviewStub.calledWithExactly('p', true));
-    });
-
-    test('_loadingChanged fired from reload in debouncer', async () => {
-      const reloadBlocker = mockPromise();
-      stubRestApi('getChangeOrEditFiles').resolves({'foo.bar': {}});
-      stubRestApi('getReviewedFiles').resolves(null);
-      stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
-      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
-
-      element.changeNum = 123;
-      element.patchRange = {patchNum: 12};
-      element._filesByPath = {'foo.bar': {}};
-      element.change = {...createParsedChange(), _number: 123};
-
-      const reloaded = element.reload();
-      assert.isTrue(element._loading);
-      assert.isFalse(element.classList.contains('loading'));
-      element.loadingTask.flush();
-      assert.isTrue(element.classList.contains('loading'));
-
-      reloadBlocker.resolve();
-      await reloaded;
-
-      assert.isFalse(element._loading);
-      element.loadingTask.flush();
-      assert.isFalse(element.classList.contains('loading'));
-    });
-
-    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;
-      element.patchRange = {patchNum: 12};
-      element.change = {...createParsedChange(), _number: 123};
-      element.reload();
-
-      assert.isTrue(element._loading);
-
-      element.loadingTask.flush();
-
-      assert.isFalse(element.classList.contains('loading'));
-    });
-
-    suite('for merge commits', () => {
-      let filesStub;
-
-      setup(async () => {
-        filesStub = stubRestApi('getChangeOrEditFiles')
-            .onFirstCall()
-            .resolves({'conflictingFile.js': {}})
-            .onSecondCall()
-            .resolves({'conflictingFile.js': {}, 'cleanlyMergedFile.js': {}});
-        stubRestApi('getReviewedFiles').resolves([]);
-        stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
-        const changeWithMultipleParents = {
-          ...createChange(),
-          revisions: {
-            r1: {
-              ...createRevision(),
-              commit: {
-                ...createCommit(),
-                parents: [
-                  {commit: 'p1', subject: 'subject1'},
-                  {commit: 'p2', subject: 'subject2'},
-                ],
-              },
-            },
-          },
-        };
-        element.changeNum = changeWithMultipleParents._number;
-        element.change = changeWithMultipleParents;
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-        await flush();
-      });
-
-      test('displays cleanly merged file count', async () => {
-        await element.reload();
-        await flush();
-
-        const message = queryAndAssert(element, '.cleanlyMergedText')
-            .textContent.trim();
-        assert.equal(message, '1 file merged cleanly in Parent 1');
-      });
-
-      test('displays plural cleanly merged file count', async () => {
-        filesStub.restore();
-        stubRestApi('getChangeOrEditFiles')
-            .onFirstCall()
-            .resolves({'conflictingFile.js': {}})
-            .onSecondCall()
-            .resolves({
-              'conflictingFile.js': {},
-              'cleanlyMergedFile.js': {},
-              'anotherCleanlyMergedFile.js': {},
-            });
-        await element.reload();
-        await flush();
-
-        const message = queryAndAssert(
-            element,
-            '.cleanlyMergedText'
-        ).textContent.trim();
-        assert.equal(message, '2 files merged cleanly in Parent 1');
-      });
-
-      test('displays button for navigating to parent 1 base', async () => {
-        await element.reload();
-        await flush();
-
-        queryAndAssert(element, '.showParentButton');
-      });
-
-      test('computes old paths for cleanly merged files', async () => {
-        filesStub.restore();
-        stubRestApi('getChangeOrEditFiles')
-            .onFirstCall()
-            .resolves({'conflictingFile.js': {}})
-            .onSecondCall()
-            .resolves({
-              'conflictingFile.js': {},
-              'cleanlyMergedFile.js': {old_path: 'cleanlyMergedFileOldName.js'},
-            });
-        await element.reload();
-        await flush();
-
-        assert.deepEqual(element._cleanlyMergedOldPaths, [
-          'cleanlyMergedFileOldName.js',
-        ]);
-      });
-
-      test('not shown for non-Auto Merge base parents', async () => {
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        await element.reload();
-        await flush();
-
-        assert.notOk(query(element, '.cleanlyMergedText'));
-        assert.notOk(query(element, '.showParentButton'));
-      });
-
-      test('not shown in edit mode', async () => {
-        element.patchRange = {basePatchNum: 1, patchNum: EditPatchSetNum};
-        await element.reload();
-        await flush();
-
-        assert.notOk(query(element, '.cleanlyMergedText'));
-        assert.notOk(query(element, '.showParentButton'));
-      });
-    });
-  });
-
-  suite('diff url file list', () => {
-    test('diff url', () => {
-      const diffStub = sinon.stub(GerritNav, 'getUrlForDiff')
-          .returns('/c/gerrit/+/1/1/index.php');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = 'index.php';
-      assert.equal(
-          element._computeDiffURL(change, undefined, 1, path, false),
-          '/c/gerrit/+/1/1/index.php');
-      diffStub.restore();
-    });
-
-    test('diff url commit msg', () => {
-      const diffStub = sinon.stub(GerritNav, 'getUrlForDiff')
-          .returns('/c/gerrit/+/1/1//COMMIT_MSG');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = '/COMMIT_MSG';
-      assert.equal(
-          element._computeDiffURL(change, undefined, 1, path, false),
-          '/c/gerrit/+/1/1//COMMIT_MSG');
-      diffStub.restore();
-    });
-
-    test('edit url', () => {
-      const editStub = sinon.stub(GerritNav, 'getEditUrlForDiff')
-          .returns('/c/gerrit/+/1/edit/index.php,edit');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = 'index.php';
-      assert.equal(
-          element._computeDiffURL(change, undefined, 1, path, true),
-          '/c/gerrit/+/1/edit/index.php,edit');
-      editStub.restore();
-    });
-
-    test('edit url commit msg', () => {
-      const editStub = sinon.stub(GerritNav, 'getEditUrlForDiff')
-          .returns('/c/gerrit/+/1/edit//COMMIT_MSG,edit');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = '/COMMIT_MSG';
-      assert.equal(
-          element._computeDiffURL(change, undefined, 1, path, true),
-          '/c/gerrit/+/1/edit//COMMIT_MSG,edit');
-      editStub.restore();
-    });
-  });
-
-  suite('size bars', () => {
-    test('_computeSizeBarLayout', () => {
-      const defaultSizeBarLayout = {
-        maxInserted: 0,
-        maxDeleted: 0,
-        maxAdditionWidth: 0,
-        maxDeletionWidth: 0,
-        deletionOffset: 0,
-      };
-
-      assert.deepEqual(
-          element._computeSizeBarLayout(null),
-          defaultSizeBarLayout);
-      assert.deepEqual(
-          element._computeSizeBarLayout({}),
-          defaultSizeBarLayout);
-      assert.deepEqual(
-          element._computeSizeBarLayout({base: []}),
-          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},
-      ];
-      const layout = element._computeSizeBarLayout({base: files});
-      assert.equal(layout.maxInserted, 5);
-      assert.equal(layout.maxDeleted, 10);
-    });
-
-    test('_computeBarAdditionWidth', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 5,
-        lines_deleted: 0,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 0,
-        maxAdditionWidth: 60,
-        maxDeletionWidth: 0,
-        deletionOffset: 60,
-      };
-
-      // Uses half the space when file is half the largest addition and there
-      // are no deletions.
-      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);
-
-      // If the insertions is not present on the file, there is no width.
-      stats.maxInserted = 10;
-      file.lines_inserted = undefined;
-      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);
-
-      // 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);
-    });
-
-    test('_computeBarAdditionX', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 5,
-        lines_deleted: 0,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 0,
-        maxAdditionWidth: 60,
-        maxDeletionWidth: 0,
-        deletionOffset: 60,
-      };
-      assert.equal(element._computeBarAdditionX(file, stats), 30);
-    });
-
-    test('_computeBarDeletionWidth', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 0,
-        lines_deleted: 5,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 10,
-        maxAdditionWidth: 30,
-        maxDeletionWidth: 30,
-        deletionOffset: 31,
-      };
-
-      // Uses a quarter the space when file is half the largest deletions and
-      // there are equal additions.
-      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);
-
-      // If the deletions is not present on the file, there is no width.
-      stats.maxDeleted = 10;
-      file.lines_deleted = undefined;
-      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);
-
-      // 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);
-    });
-
-    test('_computeSizeBarsClass', () => {
-      assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
-          'sizeBars hide');
-      assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
-          'sizeBars invisible');
-      assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
-          'sizeBars ');
-    });
-  });
-
-  suite('gr-file-list inline diff tests', () => {
-    let element;
-
-    const commitMsgComments = [
-      {
-        patch_set: 2,
-        path: '/p',
-        id: 'ecf0b9fa_fe1a5f62',
-        line: 20,
-        updated: '2018-02-08 18:49:18.000000000',
-        message: 'another comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        path: '/p',
-        id: '503008e2_0ab203ee',
-        line: 10,
-        updated: '2018-02-14 22:07:43.000000000',
-        message: 'a comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        path: '/p',
-        id: 'cc788d2c_cb1d728c',
-        line: 20,
-        in_reply_to: 'ecf0b9fa_fe1a5f62',
-        updated: '2018-02-13 22:07:43.000000000',
-        message: 'response',
-        unresolved: true,
-      },
-    ];
-
-    async function setupDiff(diff) {
-      diff.threads = diff.path === '/COMMIT_MSG' ?
-        createCommentThreads(commitMsgComments) : [];
-      diff.prefs = {
-        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,
-        theme: 'DEFAULT',
-        ignore_whitespace: 'IGNORE_NONE',
-      };
-      diff.diff = getMockDiffResponse();
-      await listenOnce(diff, 'render');
-    }
-
-    async function renderAndGetNewDiffs(index) {
-      const diffs = element.root.querySelectorAll('gr-diff-host');
-
-      for (let i = index; i < diffs.length; i++) {
-        await setupDiff(diffs[i]);
-      }
-
-      element._updateDiffCursor();
-      element.diffCursor.handleDiffUpdate();
-      return diffs;
-    }
-
-    setup(async () => {
-      stubRestApi('getPreferences').returns(Promise.resolve({}));
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
-        Promise.resolve('')
-      );
-      stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
-      stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
-      element.diffPrefs = {};
-      element.change = {_number: 42, project: 'testRepo'};
-      sinon.stub(element, '_reviewFile');
-
-      element._loading = false;
-      element.numFilesShown = 75;
-      element.selectedIndex = 0;
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
-      await flush();
-    });
-
-    test('cursor with individually opened files', async () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
-      let diffs = await renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 1);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      await flush();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      await flush();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-      assert.isFalse(diffStops[11].classList.contains('target-row'));
-
-      // The file cursor is now at 1.
-      assert.equal(element.fileCursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
-      diffs = await renderAndGetNewDiffs(1);
-
-      // Two diffs should be rendered.
-      assert.equal(diffs.length, 2);
-      const diffStopsFirst = diffs[0].getCursorStops();
-      const diffStopsSecond = diffs[1].getCursorStops();
-
-      // The line on the first diff is still selected
-      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
-      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
-    });
-
-    test('cursor with toggle all files', async () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-      await flush();
-
-      const diffs = await renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 3);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      await flush();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      await flush();
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-      assert.isTrue(diffStops[11].classList.contains('target-row'));
-
-      // The file cursor is still at 0.
-      assert.equal(element.fileCursor.index, 0);
-    });
-
-    suite('n key presses', () => {
-      let nextCommentStub;
-      let nextChunkStub;
-      let fileRows;
-
-      setup(() => {
-        sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nextCommentStub = sinon.stub(element.diffCursor,
-            'moveToNextCommentThread');
-        nextChunkStub = sinon.stub(element.diffCursor,
-            'moveToNextChunk');
-        fileRows =
-            element.root.querySelectorAll('.row:not(.header-row)');
-      });
-
-      test('n key with some files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nextChunkStub.calledOnce);
-      });
-
-      test('N key with some files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-        assert.isTrue(nextCommentStub.calledOnce);
-      });
-
-      test('n key with all files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nextChunkStub.calledOnce);
-      });
-
-      test('N key with all files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-        assert.isTrue(nextCommentStub.called);
-      });
-    });
-
-    test('_openSelectedFile behavior', async () => {
-      const _filesByPath = element._filesByPath;
-      element.set('_filesByPath', {});
-      const navStub = sinon.stub(GerritNav, 'navigateToDiff');
-      // Noop when there are no files.
-      element._openSelectedFile();
-      assert.isFalse(navStub.called);
-
-      element.set('_filesByPath', _filesByPath);
-      await flush();
-      // Navigates when a file is selected.
-      element._openSelectedFile();
-      assert.isTrue(navStub.called);
-    });
-
-    test('_displayLine', () => {
-      element.filesExpanded = FilesExpandedState.ALL;
-
-      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 = true;
-      element._handleEscKey();
-      assert.isFalse(element._displayLine);
-    });
-
-    suite('editMode behavior', () => {
-      test('reviewed checkbox', async () => {
-        element._reviewFile.restore();
-        const saveReviewStub = sinon.stub(element, '_saveReviewedState');
-
-        element.editMode = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(saveReviewStub.calledOnce);
-
-        element.editMode = true;
-        await flush();
-
-        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().then(files => {
-          assert.equal(files.length, 0);
-          assert.isFalse(apiSpy.called);
-        });
-      });
-    });
-
-    test('editing actions', async () => {
-      // Edit controls are guarded behind a dom-if initially and not rendered.
-      assert.isNotOk(dom(element.root)
-          .querySelector('gr-edit-file-controls'));
-
-      element.editMode = true;
-      await flush();
-
-      // Commit message should not have edit controls.
-      const editControls =
-          Array.from(
-              dom(element.root)
-                  .querySelectorAll('.row:not(.header-row)'))
-              .map(row => row.querySelector('gr-edit-file-controls'));
-      assert.isTrue(editControls[0].classList.contains('invisible'));
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..66df866
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -0,0 +1,2231 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import './gr-file-list';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {runA11yAudit} from '../../../test/a11y-test-utils';
+import {
+  listenOnce,
+  mockPromise,
+  query,
+  spyRestApi,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  BasePatchSetNum,
+  CommitId,
+  EditPatchSetNum,
+  NumericChangeId,
+  PatchRange,
+  PatchSetNum,
+  RepoName,
+  RevisionPatchSetNum,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {createCommentThreads} from '../../../utils/comment-util';
+import {
+  createChangeComments,
+  createCommit,
+  createDiff,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} 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');
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    await runA11yAudit(basicFixture);
+  });
+});
+
+function createFilesByPath(count: number) {
+  return Array(count)
+    .fill(0)
+    .reduce((_filesByPath, _, idx) => {
+      _filesByPath[`'/file${idx}`] = {lines_inserted: 9};
+      return _filesByPath;
+    }, {});
+}
+
+suite('gr-file-list tests', () => {
+  let element: GrFileList;
+
+  let saveStub: sinon.SinonStub;
+
+  suite('basic tests', () => {
+    setup(async () => {
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+        Promise.resolve()
+      );
+      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.numFilesShown = 200;
+      element.patchRange = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      saveStub = sinon
+        .stub(element, '_saveReviewedState')
+        .callsFake(() => Promise.resolve());
+    });
+
+    test('renders', () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `<h3
+          class="assistive-tech-only"
+        >
+          File list
+        </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">
+              C
+            </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="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
+            aria-disabled="false"
+            class="fileListButton"
+            id="incrementButton"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Show -200 more
+          </gr-button>
+          <gr-tooltip-content title="">
+            <gr-button
+              aria-disabled="false"
+              class="fileListButton"
+              id="showAllButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Show all 0 files
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+        <gr-diff-preferences-dialog
+          id="diffPreferencesDialog"
+        ></gr-diff-preferences-dialog>`);
+    });
+
+    test('renders file row', () => {
+      element._filesByPath = createFilesByPath(1);
+      flush();
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      expect(fileRows?.[0]).dom.equal(/* HTML */ `<div
+        class="file-row row"
+        data-file='{"path":"&apos;/file0"}'
+        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>
+            <span class="truncatedFileName" title="'/file0"> …/file0 </span>
+            <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">
+            <span class="drafts"> </span> <span> </span>
+            <span class="noCommentsScreenReaderText"> No comments </span>
+          </div>
+          <div class="comments mobile">
+            <span class="drafts"> </span> <span> </span>
+            <span class="noCommentsScreenReaderText"> No comments </span>
+          </div>
+        </div>
+        <div class="desktop" role="gridcell">
+          <div
+            aria-label="A bar that represents the addition and deletion ratio for the current file"
+            class="sizeBars"
+          ></div>
+        </div>
+        <div class="stats" role="gridcell">
+          <div>
+            <span aria-label="9 lines added" class="added" tabindex="0">
+              +9
+            </span>
+            <span aria-label="0 lines removed" class="removed" tabindex="0">
+              -0
+            </span>
+            <span hidden="true"> +/-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 class="show-hide" role="gridcell">
+          <span
+            aria-checked="false"
+            aria-label="Expand file"
+            class="show-hide"
+            data-expand="true"
+            data-path="'/file0"
+            role="switch"
+            tabindex="0"
+          >
+            <iron-icon class="show-hide-icon" id="icon" tabindex="-1">
+            </iron-icon>
+          </span>
+        </div>
+      </div>`);
+    });
+
+    test('correct number of files are shown', () => {
+      element.fileListIncrement = 300;
+      element._filesByPath = createFilesByPath(500);
+
+      flush();
+      assert.equal(
+        queryAll<HTMLDivElement>(element, '.file-row').length,
+        element.numFilesShown
+      );
+      const controlRow = queryAndAssert<HTMLDivElement>(element, '.controlRow');
+      assert.isFalse(controlRow.classList.contains('invisible'));
+      assert.equal(
+        queryAndAssert<GrButton>(
+          element,
+          '#incrementButton'
+        ).textContent!.trim(),
+        'Show 300 more'
+      );
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#showAllButton').textContent!.trim(),
+        'Show all 500 files'
+      );
+
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#showAllButton'));
+      flush();
+
+      assert.equal(element.numFilesShown, 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);
+      assert.equal(queryAll<HTMLDivElement>(element, '.file-row').length, 10);
+      assert.equal(renderedStub.callCount, 10);
+    });
+
+    test('calculate totals for patch number', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with a commit message that isn't the first file.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with no commit message.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with files missing either lines_inserted or lines_deleted.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+        'myfile.txt': {
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('binary only files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        file_binary_1: {binary: true, size_delta: 10, size: 100},
+        file_binary_2: {binary: true, size_delta: -5, size: 120},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isTrue(element._hideChangeTotals);
+    });
+
+    test('binary and regular files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        file_binary_1: {binary: true, size_delta: 10, size: 100},
+        file_binary_2: {binary: true, size_delta: -5, size: 120},
+        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
+        'myfile2.txt': {
+          lines_inserted: 10,
+          size: 0,
+          size_delta: 0,
+        },
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('_formatBytes function', () => {
+      const table = {
+        '64': '+64 B',
+        '1023': '+1023 B',
+        '1024': '+1 KiB',
+        '4096': '+4 KiB',
+        '1073741824': '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        '0': '+/-0 B',
+      };
+      for (const [bytes, expected] of Object.entries(table)) {
+        assert.equal(element._formatBytes(Number(bytes)), expected);
+      }
+    });
+
+    test('_formatPercentage function', () => {
+      const table = [
+        {size: 100, delta: 100, display: ''},
+        {size: 195060, delta: 64, display: '(+0%)'},
+        {size: 195060, delta: -64, display: '(-0%)'},
+        {size: 394892, delta: -7128, display: '(-2%)'},
+        {size: 90, delta: -10, display: '(-10%)'},
+        {size: 110, delta: 10, display: '(+10%)'},
+      ];
+
+      for (const item of table) {
+        assert.equal(
+          element._formatPercentage(item.size, item.delta),
+          item.display
+        );
+      }
+    });
+
+    test('comment filtering', () => {
+      element.changeComments = createChangeComments();
+      const parentTo1 = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+
+      const parentTo2 = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+
+      const _1To2 = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+
+      assert.equal(
+        element._computeCommentsStringMobile(
+          element.changeComments,
+          parentTo1,
+          {__path: '/COMMIT_MSG', size: 0, size_delta: 0}
+        ),
+        '2c'
+      );
+      assert.equal(
+        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, parentTo1, {
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1 draft'
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, _1To2, {
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1 draft'
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1d'
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1d'
+      );
+      assert.equal(
+        element._computeCommentsStringMobile(
+          element.changeComments,
+          parentTo1,
+          {__path: 'myfile.txt', size: 0, size_delta: 0}
+        ),
+        '1c'
+      );
+      assert.equal(
+        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, parentTo1, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, _1To2, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      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, {
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, parentTo1, {
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, _1To2, {
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeCommentsStringMobile(
+          element.changeComments,
+          parentTo2,
+          {__path: '/COMMIT_MSG', size: 0, size_delta: 0}
+        ),
+        '1c'
+      );
+      assert.equal(
+        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, parentTo1, {
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2 drafts'
+      );
+      assert.equal(
+        element._computeDraftsString(element.changeComments, _1To2, {
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2 drafts'
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2d'
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2d'
+      );
+      assert.equal(
+        element._computeCommentsStringMobile(
+          element.changeComments,
+          parentTo2,
+          {__path: 'myfile.txt', size: 0, size_delta: 0}
+        ),
+        '2c'
+      );
+      assert.equal(
+        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, parentTo2, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      assert.equal(
+        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+    });
+
+    test('_reviewedTitle', () => {
+      assert.equal(
+        element._reviewedTitle(true),
+        'Mark as not reviewed (shortcut: r)'
+      );
+
+      assert.equal(
+        element._reviewedTitle(false),
+        'Mark as reviewed (shortcut: r)'
+      );
+    });
+
+    suite('keyboard shortcuts', () => {
+      setup(() => {
+        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.changeNum = 42 as NumericChangeId;
+        element.patchRange = {
+          basePatchNum: 'PARENT' as BasePatchSetNum,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        element.change = {_number: 42 as NumericChangeId} as ParsedChangeInfo;
+        element.fileCursor.setCursorAtIndex(0);
+      });
+
+      test('toggle left diff via shortcut', () => {
+        const toggleLeftDiffStub = sinon.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        const diffsStub = sinon
+          .stub(element, 'diffs')
+          .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
+        assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
+      });
+
+      test('keyboard shortcuts', () => {
+        flush();
+
+        const items = [...queryAll<HTMLDivElement>(element, '.file-row')];
+        element.fileCursor.stops = items;
+        element.fileCursor.setCursorAtIndex(0);
+        assert.equal(items.length, 3);
+        assert.isTrue(items[0].classList.contains('selected'));
+        assert.isFalse(items[1].classList.contains('selected'));
+        assert.isFalse(items[2].classList.contains('selected'));
+        // j with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'J');
+        assert.equal(element.fileCursor.index, 0);
+        // down should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
+        assert.equal(element.fileCursor.index, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+
+        const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+        assert.equal(element.fileCursor.index, 2);
+        assert.equal(element.selectedIndex, 2);
+
+        // k with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'K');
+        assert.equal(element.fileCursor.index, 2);
+
+        // up should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
+        assert.equal(element.fileCursor.index, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
+
+        assert(
+          navStub.lastCall.calledWith(
+            element.change,
+            'file_added_in_rev2.txt',
+            2 as PatchSetNum
+          ),
+          'Should navigate to /c/42/2/file_added_in_rev2.txt'
+        );
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+
+        const createCommentInPlaceStub = sinon.stub(
+          element.diffCursor,
+          'createCommentInPlace'
+        );
+        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
+        assert.isTrue(createCommentInPlaceStub.called);
+      });
+
+      test('i key shows/hides selected inline diff', () => {
+        const paths = Object.keys(element._filesByPath!);
+        sinon.stub(element, '_expandedFilesChanged');
+        flush();
+        const files = [...queryAll<HTMLDivElement>(element, '.file-row')];
+        element.fileCursor.stops = files;
+        element.fileCursor.setCursorAtIndex(0);
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        flush();
+        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]);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        flush();
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+
+        element.fileCursor.setCursorAtIndex(1);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        flush();
+        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]);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
+        flush();
+        assert.equal(element.diffs.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));
+        }
+        // since _expandedFilesChanged is stubbed
+        element.filesExpanded = FilesExpandedState.ALL;
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
+        flush();
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+      });
+
+      test('r key toggles reviewed flag', () => {
+        const reducer = (accum: number, file: NormalizedFileInfo) =>
+          file.isReviewed ? ++accum : accum;
+        const getNumReviewed = () => element._files.reduce(reducer, 0);
+        flush();
+
+        // Default state should be unreviewed.
+        assert.equal(getNumReviewed(), 0);
+
+        // Press the review key to toggle it (set the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        flush();
+        assert.equal(getNumReviewed(), 1);
+
+        // Press the review key to toggle it (clear the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.equal(getNumReviewed(), 0);
+      });
+
+      suite('handleOpenFile', () => {
+        let interact: Function;
+
+        setup(() => {
+          const openCursorStub = sinon.stub(element, '_openCursorFile');
+          const openSelectedStub = sinon.stub(element, '_openSelectedFile');
+          const expandStub = sinon.stub(element, '_toggleFileExpanded');
+
+          interact = function () {
+            openCursorStub.reset();
+            openSelectedStub.reset();
+            expandStub.reset();
+            element.handleOpenFile();
+            const result = {} as any;
+            if (openCursorStub.called) {
+              result.opened_cursor = true;
+            }
+            if (openSelectedStub.called) {
+              result.opened_selected = true;
+            }
+            if (expandStub.called) {
+              result.expanded = true;
+            }
+            return result;
+          };
+        });
+
+        test('open from selected file', () => {
+          element.filesExpanded = FilesExpandedState.NONE;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+
+        test('open from diff cursor', () => {
+          element.filesExpanded = FilesExpandedState.ALL;
+          assert.deepEqual(interact(), {opened_cursor: true});
+        });
+
+        test('expand when user prefers', () => {
+          element.filesExpanded = FilesExpandedState.NONE;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+      });
+
+      test('shift+left/shift+right', () => {
+        const moveLeftStub = sinon.stub(element.diffCursor, 'moveLeft');
+        const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
+
+        let noDiffsExpanded = true;
+        sinon
+          .stub(element, '_noDiffsExpanded')
+          .callsFake(() => noDiffsExpanded);
+
+        MockInteractions.pressAndReleaseKeyOn(
+          element,
+          73,
+          'shift',
+          'ArrowLeft'
+        );
+        assert.isFalse(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(
+          element,
+          73,
+          'shift',
+          'ArrowRight'
+        );
+        assert.isFalse(moveRightStub.called);
+
+        noDiffsExpanded = false;
+
+        MockInteractions.pressAndReleaseKeyOn(
+          element,
+          73,
+          'shift',
+          'ArrowLeft'
+        );
+        assert.isTrue(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(
+          element,
+          73,
+          'shift',
+          'ArrowRight'
+        );
+        assert.isTrue(moveRightStub.called);
+      });
+    });
+
+    test('file review status', () => {
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      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.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 fileRows = queryAll(element, '.row:not(.header-row)');
+      const checkSelector = 'span.reviewedSwitch[role="switch"]';
+      const commitMsg = fileRows[0].querySelector(checkSelector);
+      const fileAdded = fileRows[1].querySelector(checkSelector);
+      const myFile = fileRows[2].querySelector(checkSelector);
+
+      assert.equal(commitMsg!.getAttribute('aria-checked'), 'true');
+      assert.equal(fileAdded!.getAttribute('aria-checked'), 'false');
+      assert.equal(myFile!.getAttribute('aria-checked'), 'true');
+
+      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
+      const markReviewLabel = fileRows[0].querySelector('.markReviewed');
+      assert.isTrue(commitReviewLabel!.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK UNREVIEWED');
+
+      const clickSpy = sinon.spy(element, '_reviewedClick');
+      MockInteractions.tap(markReviewLabel!);
+      // assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
+      // assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK REVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledOnce);
+
+      MockInteractions.tap(markReviewLabel!);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      assert.isTrue(commitReviewLabel!.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK UNREVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledTwice);
+
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('_handleFileListClick', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {size: 0, size_delta: 0},
+        'f1.txt': {size: 0, size_delta: 0},
+        'f2.txt': {size: 0, size_delta: 0},
+      };
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+
+      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.
+      queryAndAssert<HTMLDivElement>(row, 'div.show-hide').click();
+      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.
+      queryAndAssert<GrDiffHost>(element, 'gr-diff-host').click();
+      assert.isTrue(clickSpy.calledTwice);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isFalse(reviewStub.called);
+    });
+
+    test('_handleFileListClick editMode', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {size: 0, size_delta: 0},
+        'f1.txt': {size: 0, size_delta: 0},
+        'f2.txt': {size: 0, size_delta: 0},
+      };
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
+      flush();
+      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'));
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('checkbox shows/hides diff inline', () => {
+      element._filesByPath = {
+        'myfile.txt': {size: 0, size_delta: 0},
+      };
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      element.fileCursor.setCursorAtIndex(0);
+      sinon.stub(element, '_expandedFilesChanged');
+      flush();
+      const fileRows = queryAll(element, '.row:not(.header-row)');
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      const showHideCheck = fileRows[0].querySelector(
+        'span.show-hide[role="switch"]'
+      );
+      const showHideLabel = showHideCheck!.querySelector('.show-hide-icon');
+      assert.equal(showHideCheck!.getAttribute('aria-checked'), 'false');
+      MockInteractions.tap(showHideLabel!);
+      assert.equal(showHideCheck!.getAttribute('aria-checked'), 'true');
+      assert.notEqual(
+        element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
+        -1
+      );
+    });
+
+    test('diff mode correctly toggles the diffs', () => {
+      element._filesByPath = {
+        'myfile.txt': {size: 0, size_delta: 0},
+      };
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      const updateDiffPrefSpy = sinon.spy(element, '_updateDiffPreferences');
+      element.fileCursor.setCursorAtIndex(0);
+      flush();
+
+      // 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');
+      assert.isTrue(updateDiffPrefSpy.called);
+    });
+
+    test('expanded attribute not set on path when not expanded', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {size: 0, size_delta: 0},
+      };
+      assert.isNotOk(query(element, 'expanded'));
+    });
+
+    test('tapping row ignores links', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {size: 0, size_delta: 0},
+      };
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      sinon.stub(element, '_expandedFilesChanged');
+      flush();
+      const commitMsgFile = queryAll(
+        element,
+        '.row:not(.header-row) a.pathLink'
+      )[0];
+
+      // Remove href attribute so the app doesn't route to a diff view
+      commitMsgFile.removeAttribute('href');
+      const togglePathSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      MockInteractions.tap(commitMsgFile);
+      flush();
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
+      assert.isNotOk(query(element, '.expanded'));
+      assert.notEqual(
+        getComputedStyle(queryAndAssert(element, '.show-hide')).display,
+        'none'
+      );
+    });
+
+    test('_toggleFileExpanded', () => {
+      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');
+
+      assert.equal(
+        queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
+        'gr-icons:expand-more'
+      );
+      assert.equal(element._expandedFiles.length, 0);
+      element._toggleFileExpanded({path});
+      flush();
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+      assert.equal(
+        queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
+        'gr-icons:expand-less'
+      );
+
+      assert.equal(renderSpy.callCount, 1);
+      assert.isTrue(element._expandedFiles.some(f => f.path === path));
+      element._toggleFileExpanded({path});
+      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.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('expandAllDiffs and collapseAllDiffs', () => {
+      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+      const cursorUpdateStub = sinon.stub(
+        element.diffCursor,
+        'handleDiffUpdate'
+      );
+      const reInitStub = sinon.stub(element.diffCursor, 'reInitAndUpdateStops');
+
+      const path = 'path/to/my/file.txt';
+      element._filesByPath = {[path]: {size: 0, size_delta: 0}};
+      element.expandAllDiffs();
+      flush();
+      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+      assert.isTrue(reInitStub.calledOnce);
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+
+      element.collapseAllDiffs();
+      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');
+      const path = 'path/to/my/file.txt';
+      const promise = mockPromise();
+      const diffs = [
+        {
+          path,
+          style: {},
+          reload() {
+            promise.resolve();
+          },
+          prefetchDiff() {},
+          cancel() {},
+          getCursorStops() {
+            return [];
+          },
+          addEventListener(eventName: string, callback: Function) {
+            if (
+              ['render-start', 'render-content', 'scroll'].indexOf(eventName) >=
+              0
+            ) {
+              callback(new Event(eventName));
+            }
+          },
+        },
+      ];
+      sinon.stub(element, 'diffs').get(() => diffs);
+      element.push('_expandedFiles', {path});
+      await promise;
+    });
+
+    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.
+      const diff = {
+        cancel: sinon.stub(),
+        clearDiffContent: sinon.stub(),
+      } as any;
+      element._clearCollapsedDiffs([diff]);
+      assert.isTrue(diff.cancel.calledOnce);
+      assert.isTrue(diff.clearDiffContent.calledOnce);
+    });
+
+    test('filesExpanded value updates to correct enum', () => {
+      element._filesByPath = {
+        'foo.bar': {size: 0, size_delta: 0},
+        'baz.bar': {size: 0, size_delta: 0},
+      };
+      flush();
+      assert.equal(element.filesExpanded, FilesExpandedState.NONE);
+      element.push('_expandedFiles', {path: 'baz.bar'});
+      flush();
+      assert.equal(element.filesExpanded, FilesExpandedState.SOME);
+      element.push('_expandedFiles', {path: 'foo.bar'});
+      flush();
+      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+      element.collapseAllDiffs();
+      flush();
+      assert.equal(element.filesExpanded, FilesExpandedState.NONE);
+      element.expandAllDiffs();
+      flush();
+      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+    });
+
+    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
+      // methods / properties that it isn't worth it.
+      const diffs = [
+        {
+          path: 'p0',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(callCount++, 2);
+            return Promise.resolve();
+          },
+        },
+        {
+          path: 'p1',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(callCount++, 1);
+            return Promise.resolve();
+          },
+        },
+        {
+          path: 'p2',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(callCount++, 0);
+            return Promise.resolve();
+          },
+        },
+      ] as any;
+      element._renderInOrder(
+        [{path: 'p2'}, {path: 'p1'}, {path: 'p0'}],
+        diffs,
+        3
+      );
+      await flush();
+      assert.isFalse(reviewStub.called);
+    });
+
+    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
+      // methods / properties that it isn't worth it.
+      const diffs = [
+        {
+          path: 'p2',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(reviewStub.callCount, 0);
+            assert.equal(callCount++, 0);
+            return Promise.resolve();
+          },
+        },
+      ] as any;
+      element._renderInOrder([{path: 'p2'}], diffs, 1);
+      await flush();
+      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');
+      // 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.
+      const diffs = [
+        {
+          path: 'p',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            return Promise.resolve();
+          },
+        },
+      ] as any;
+
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isFalse(reviewStub.called);
+      delete element.diffPrefs.manual_review;
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isTrue(reviewStub.called);
+      assert.isTrue(reviewStub.calledWithExactly('p', true));
+    });
+
+    test('_loadingChanged fired from reload in debouncer', async () => {
+      const reloadBlocker = mockPromise();
+      stubRestApi('getChangeOrEditFiles').resolves({
+        'foo.bar': {size: 0, size_delta: 0},
+      });
+      stubRestApi('getReviewedFiles').resolves(undefined);
+      stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
+      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
+
+      element.changeNum = 123 as NumericChangeId;
+      element.patchRange = {patchNum: 12 as RevisionPatchSetNum} as PatchRange;
+      element._filesByPath = {'foo.bar': {size: 0, size_delta: 0}};
+      element.change = {
+        ...createParsedChange(),
+        _number: 123 as NumericChangeId,
+      };
+
+      const reloaded = element.reload();
+      assert.isTrue(element._loading);
+      assert.isFalse(element.classList.contains('loading'));
+      element.loadingTask!.flush();
+      assert.isTrue(element.classList.contains('loading'));
+
+      reloadBlocker.resolve();
+      await reloaded;
+
+      assert.isFalse(element._loading);
+      element.loadingTask!.flush();
+      assert.isFalse(element.classList.contains('loading'));
+    });
+
+    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 = {
+        ...createParsedChange(),
+        _number: 123 as NumericChangeId,
+      };
+      element.reload();
+
+      assert.isTrue(element._loading);
+
+      element.loadingTask!.flush();
+
+      assert.isFalse(element.classList.contains('loading'));
+    });
+
+    suite('for merge commits', () => {
+      let filesStub: sinon.SinonStub;
+
+      setup(async () => {
+        filesStub = stubRestApi('getChangeOrEditFiles')
+          .onFirstCall()
+          .resolves({'conflictingFile.js': {size: 0, size_delta: 0}})
+          .onSecondCall()
+          .resolves({
+            'conflictingFile.js': {size: 0, size_delta: 0},
+            'cleanlyMergedFile.js': {size: 0, size_delta: 0},
+          });
+        stubRestApi('getReviewedFiles').resolves([]);
+        stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
+        const changeWithMultipleParents = {
+          ...createParsedChange(),
+          revisions: {
+            r1: {
+              ...createRevision(),
+              commit: {
+                ...createCommit(),
+                parents: [
+                  {commit: 'p1' as CommitId, subject: 'subject1'},
+                  {commit: 'p2' as CommitId, subject: 'subject2'},
+                ],
+              },
+            },
+          },
+        };
+        element.changeNum = changeWithMultipleParents._number;
+        element.change = changeWithMultipleParents;
+        element.patchRange = {
+          basePatchNum: 'PARENT' as BasePatchSetNum,
+          patchNum: 1 as RevisionPatchSetNum,
+        };
+        await flush();
+      });
+
+      test('displays cleanly merged file count', async () => {
+        await element.reload();
+        await flush();
+
+        const message = queryAndAssert<HTMLSpanElement>(
+          element,
+          '.cleanlyMergedText'
+        ).textContent!.trim();
+        assert.equal(message, '1 file merged cleanly in Parent 1');
+      });
+
+      test('displays plural cleanly merged file count', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+          .onFirstCall()
+          .resolves({'conflictingFile.js': {size: 0, size_delta: 0}})
+          .onSecondCall()
+          .resolves({
+            'conflictingFile.js': {size: 0, size_delta: 0},
+            'cleanlyMergedFile.js': {size: 0, size_delta: 0},
+            'anotherCleanlyMergedFile.js': {size: 0, size_delta: 0},
+          });
+        await element.reload();
+        await flush();
+
+        const message = queryAndAssert(
+          element,
+          '.cleanlyMergedText'
+        ).textContent!.trim();
+        assert.equal(message, '2 files merged cleanly in Parent 1');
+      });
+
+      test('displays button for navigating to parent 1 base', async () => {
+        await element.reload();
+        await flush();
+
+        queryAndAssert(element, '.showParentButton');
+      });
+
+      test('computes old paths for cleanly merged files', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+          .onFirstCall()
+          .resolves({'conflictingFile.js': {size: 0, size_delta: 0}})
+          .onSecondCall()
+          .resolves({
+            'conflictingFile.js': {size: 0, size_delta: 0},
+            'cleanlyMergedFile.js': {
+              old_path: 'cleanlyMergedFileOldName.js',
+              size: 0,
+              size_delta: 0,
+            },
+          });
+        await element.reload();
+        await flush();
+
+        assert.deepEqual(element._cleanlyMergedOldPaths, [
+          'cleanlyMergedFileOldName.js',
+        ]);
+      });
+
+      test('not shown for non-Auto Merge base parents', async () => {
+        element.patchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        await element.reload();
+        await flush();
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+
+      test('not shown in edit mode', async () => {
+        element.patchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: EditPatchSetNum,
+        };
+        await element.reload();
+        await flush();
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+    });
+  });
+
+  suite('diff url file list', () => {
+    test('diff url', () => {
+      const diffStub = sinon
+        .stub(GerritNav, 'getUrlForDiff')
+        .returns('/c/gerrit/+/1/1/index.php');
+      const change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      const path = 'index.php';
+      assert.equal(
+        element._computeDiffURL(
+          change,
+          undefined,
+          1 as RevisionPatchSetNum,
+          path,
+          false
+        ),
+        '/c/gerrit/+/1/1/index.php'
+      );
+      diffStub.restore();
+    });
+
+    test('diff url commit msg', () => {
+      const diffStub = sinon
+        .stub(GerritNav, 'getUrlForDiff')
+        .returns('/c/gerrit/+/1/1//COMMIT_MSG');
+      const change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      const path = '/COMMIT_MSG';
+      assert.equal(
+        element._computeDiffURL(
+          change,
+          undefined,
+          1 as RevisionPatchSetNum,
+          path,
+          false
+        ),
+        '/c/gerrit/+/1/1//COMMIT_MSG'
+      );
+      diffStub.restore();
+    });
+
+    test('edit url', () => {
+      const editStub = sinon
+        .stub(GerritNav, 'getEditUrlForDiff')
+        .returns('/c/gerrit/+/1/edit/index.php,edit');
+      const change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      const path = 'index.php';
+      assert.equal(
+        element._computeDiffURL(
+          change,
+          undefined,
+          1 as RevisionPatchSetNum,
+          path,
+          true
+        ),
+        '/c/gerrit/+/1/edit/index.php,edit'
+      );
+      editStub.restore();
+    });
+
+    test('edit url commit msg', () => {
+      const editStub = sinon
+        .stub(GerritNav, 'getEditUrlForDiff')
+        .returns('/c/gerrit/+/1/edit//COMMIT_MSG,edit');
+      const change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      const path = '/COMMIT_MSG';
+      assert.equal(
+        element._computeDiffURL(
+          change,
+          undefined,
+          1 as RevisionPatchSetNum,
+          path,
+          true
+        ),
+        '/c/gerrit/+/1/edit//COMMIT_MSG,edit'
+      );
+      editStub.restore();
+    });
+  });
+
+  suite('size bars', () => {
+    test('_computeSizeBarLayout', () => {
+      const defaultSizeBarLayout = {
+        maxInserted: 0,
+        maxDeleted: 0,
+        maxAdditionWidth: 0,
+        maxDeletionWidth: 0,
+        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
+      );
+
+      const files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 10000},
+        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
+        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
+      ];
+      const layout = element._computeSizeBarLayout({
+        base: files,
+      } as PolymerDeepPropertyChange<NormalizedFileInfo[], NormalizedFileInfo[]>);
+      assert.equal(layout.maxInserted, 5);
+      assert.equal(layout.maxDeleted, 10);
+    });
+
+    test('_computeBarAdditionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+        size: 0,
+        size_delta: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+
+      // Uses half the space when file is half the largest addition and there
+      // are no deletions.
+      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);
+
+      // 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);
+
+      // If the file is a commit message, returns zero.
+      file.lines_inserted = 5;
+      file.__path = '/COMMIT_MSG';
+      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);
+    });
+
+    test('_computeBarAdditionX', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+        size: 0,
+        size_delta: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+      assert.equal(element._computeBarAdditionX(file, stats), 30);
+    });
+
+    test('_computeBarDeletionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 0,
+        lines_deleted: 5,
+        size: 0,
+        size_delta: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 10,
+        maxAdditionWidth: 30,
+        maxDeletionWidth: 30,
+        deletionOffset: 31,
+      };
+
+      // Uses a quarter the space when file is half the largest deletions and
+      // there are equal additions.
+      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);
+
+      // 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);
+
+      // If the file is a commit message, returns zero.
+      file.lines_deleted = 5;
+      file.__path = '/COMMIT_MSG';
+      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);
+    });
+
+    test('_computeSizeBarsClass', () => {
+      assert.equal(
+        element._computeSizeBarsClass(false, 'foo/bar.baz'),
+        'sizeBars hide'
+      );
+      assert.equal(
+        element._computeSizeBarsClass(true, '/COMMIT_MSG'),
+        'sizeBars invisible'
+      );
+      assert.equal(
+        element._computeSizeBarsClass(true, 'foo/bar.baz'),
+        'sizeBars '
+      );
+    });
+  });
+
+  suite('gr-file-list inline diff tests', () => {
+    let element: GrFileList;
+    let reviewFileStub: sinon.SinonStub;
+
+    const commitMsgComments = [
+      {
+        patch_set: 2 as PatchSetNum,
+        path: '/p',
+        id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        line: 20,
+        updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+        message: 'another comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2 as PatchSetNum,
+        path: '/p',
+        id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+        line: 10,
+        updated: '2018-02-14 22:07:43.000000000' as Timestamp,
+        message: 'a comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2 as PatchSetNum,
+        path: '/p',
+        id: 'cc788d2c_cb1d728c' as UrlEncodedCommentId,
+        line: 20,
+        in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        updated: '2018-02-13 22:07:43.000000000' as Timestamp,
+        message: 'response',
+        unresolved: true,
+      },
+    ];
+
+    async function setupDiff(diff: GrDiffHost) {
+      diff.threads =
+        diff.path === '/COMMIT_MSG'
+          ? createCommentThreads(commitMsgComments)
+          : [];
+      diff.prefs = {
+        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',
+      };
+      diff.diff = createDiff();
+      await listenOnce(diff, 'render');
+    }
+
+    async function renderAndGetNewDiffs(index: number) {
+      const diffs = queryAll<GrDiffHost>(element, 'gr-diff-host');
+
+      for (let i = index; i < diffs.length; i++) {
+        await setupDiff(diffs[i]);
+      }
+
+      element._updateDiffCursor();
+      element.diffCursor.handleDiffUpdate();
+      return diffs;
+    }
+
+    setup(async () => {
+      stubRestApi('getPreferences').returns(Promise.resolve(undefined));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+        Promise.resolve()
+      );
+      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.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        project: 'testRepo' as RepoName,
+      };
+      reviewFileStub = sinon.stub(element, '_reviewFile');
+
+      element._loading = false;
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9, size: 0, size_delta: 0},
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 'PARENT' as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      sinon
+        .stub(window, 'fetch')
+        .callsFake(() => Promise.resolve(new Response()));
+      await flush();
+    });
+
+    test('cursor with individually opened files', async () => {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      await flush();
+      let diffs = await renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+
+      // No line number is selected.
+      assert.isFalse(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(
+        queryAll(diffStops[10] as HTMLElement, '.contentText')[0]
+      );
+      await flush();
+      assert.isTrue(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      await flush();
+      assert.isTrue(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+      assert.isFalse(
+        (diffStops[11] as HTMLElement).classList.contains('target-row')
+      );
+
+      // The file cursor is now at 1.
+      assert.equal(element.fileCursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      await flush();
+      diffs = await renderAndGetNewDiffs(1);
+
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      const diffStopsFirst = diffs[0].getCursorStops();
+      const diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is still selected
+      assert.isTrue(
+        (diffStopsFirst[10] as HTMLElement).classList.contains('target-row')
+      );
+      assert.isFalse(
+        (diffStopsSecond[10] as HTMLElement).classList.contains('target-row')
+      );
+    });
+
+    test('cursor with toggle all files', async () => {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
+      await flush();
+
+      const diffs = await renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+
+      // No line number is selected.
+      assert.isFalse(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(
+        queryAll(diffStops[10] as HTMLElement, '.contentText')[0]
+      );
+      await flush();
+      assert.isTrue(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      await flush();
+      assert.isFalse(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+      assert.isTrue(
+        (diffStops[11] as HTMLElement).classList.contains('target-row')
+      );
+
+      // The file cursor is still at 0.
+      assert.equal(element.fileCursor.index, 0);
+    });
+
+    suite('n key presses', () => {
+      let nextCommentStub: sinon.SinonStub;
+      let nextChunkStub: sinon.SinonStub;
+      let fileRows: NodeListOf<HTMLDivElement>;
+
+      setup(() => {
+        sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
+        nextCommentStub = sinon.stub(
+          element.diffCursor,
+          'moveToNextCommentThread'
+        );
+        nextChunkStub = sinon.stub(element.diffCursor, 'moveToNextChunk');
+        fileRows = queryAll<HTMLDivElement>(element, '.row:not(.header-row)');
+      });
+
+      test('n key with some files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nextChunkStub.calledOnce);
+      });
+
+      test('N key with some files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        assert.isTrue(nextCommentStub.calledOnce);
+      });
+
+      test('n key with all files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nextChunkStub.calledOnce);
+      });
+
+      test('N key with all files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        assert.isTrue(nextCommentStub.called);
+      });
+    });
+
+    test('_openSelectedFile behavior', async () => {
+      const _filesByPath = element._filesByPath;
+      element.set('_filesByPath', {});
+      const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+      // Noop when there are no files.
+      element._openSelectedFile();
+      assert.isFalse(navStub.called);
+
+      element.set('_filesByPath', _filesByPath);
+      await flush();
+      // Navigates when a file is selected.
+      element._openSelectedFile();
+      assert.isTrue(navStub.called);
+    });
+
+    test('_displayLine', () => {
+      element.filesExpanded = FilesExpandedState.ALL;
+
+      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 = true;
+      element._handleEscKey();
+      assert.isFalse(element._displayLine);
+    });
+
+    suite('editMode behavior', () => {
+      test('reviewed checkbox', async () => {
+        reviewFileStub.restore();
+        const saveReviewStub = sinon.stub(element, '_saveReviewedState');
+
+        element.editMode = false;
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+
+        element.editMode = true;
+        await flush();
+
+        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 () => {
+      // Edit controls are guarded behind a dom-if initially and not rendered.
+      assert.isNotOk(
+        query<GrEditFileControls>(element, 'gr-edit-file-controls')
+      );
+
+      element.editMode = true;
+      await flush();
+
+      // Commit message should not have edit controls.
+      const editControls = Array.from(
+        queryAll(element, '.row:not(.header-row)')
+      ).map(row => row.querySelector('gr-edit-file-controls'));
+      assert.isTrue(editControls[0]!.classList.contains('invisible'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index e363941..6bd3f62 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -17,31 +17,18 @@
 import '@polymer/iron-selector/iron-selector';
 import '../../shared/gr-button/gr-button';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {css, html, nothing, LitElement} from 'lit';
+import {css, html, LitElement} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {ifDefined} from 'lit/directives/if-defined';
 import {IronSelectorElement} from '@polymer/iron-selector/iron-selector';
 import {
-  LabelNameToValueMap,
   LabelNameToInfoMap,
   QuickLabelInfo,
   DetailedLabelInfo,
 } from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {classMap} from 'lit/directives/class-map';
-
-export interface Label {
-  name: string;
-  value: string | null;
-}
-
-// TODO(TS): add description to explain what this is after moving
-// gr-label-scores to ts
-export interface LabelValuesMap {
-  [key: number]: number;
-}
+import {Label} from '../../../utils/label-util';
+import {LabelNameToValuesMap} from '../../../api/rest-api';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -70,20 +57,14 @@
   name?: string;
 
   @property({type: Object})
-  permittedLabels: LabelNameToValueMap | undefined | null;
+  permittedLabels: LabelNameToValuesMap | undefined | null;
 
-  @property({type: Object})
-  labelValues?: LabelValuesMap;
+  @property({type: Array})
+  orderedLabelValues?: number[];
 
   @state()
   private selectedValueText = 'No value selected';
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   static override get styles() {
     return [
       sharedStyles,
@@ -96,9 +77,7 @@
         }
         /* We want the :hover highlight to extend to the border of the dialog. */
         .labelNameCell {
-          padding-left: var(--spacing-xl);
-        }
-        .labelNameCell.newSubmitRequirements {
+          padding-left: var(--label-score-padding-left, 0);
           width: 160px;
         }
         .selectedValueCell {
@@ -110,9 +89,6 @@
           white-space: nowrap;
         }
         .selectedValueCell {
-          width: 75%;
-        }
-        .selectedValueCell.newSubmitRequirements {
           width: 52%;
         }
         .labelMessage {
@@ -185,13 +161,7 @@
 
   override render() {
     return html`
-      <span
-        class="${classMap({
-          labelNameCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}"
-        id="labelName"
-        aria-hidden="true"
+      <span class="labelNameCell" id="labelName" aria-hidden="true"
         >${this.label?.name ?? ''}</span
       >
       ${this.renderButtonsCell()} ${this.renderSelectedValue()}
@@ -203,23 +173,21 @@
       <div class="buttonsCell">
         ${this.renderBlankItems('start')} ${this.renderLabelSelector()}
         ${this.renderBlankItems('end')}
-        ${!this._computeAnyPermittedLabelValues()
-          ? html` <span class="labelMessage">
-              You don't have permission to edit this label.
-            </span>`
-          : nothing}
       </div>
     `;
   }
 
+  // Render blank cells so that all same value votes are aligned
   private renderBlankItems(position: string) {
-    const blankItems = this._computeBlankItems(position);
-    return blankItems.map(
-      _value => html`
-        <span class="placeholder" data-label="${this.label?.name ?? ''}">
-        </span>
-      `
-    );
+    const blankItemCount = this.computeBlankItemsCount(position);
+    return new Array(blankItemCount)
+      .fill('')
+      .map(
+        () => html`
+          <span class="placeholder" data-label=${this.label?.name ?? ''}>
+          </span>
+        `
+      );
   }
 
   private renderLabelSelector() {
@@ -227,8 +195,7 @@
       <iron-selector
         id="labelSelector"
         .attrForSelected=${'data-value'}
-        ?hidden="${!this._computeAnyPermittedLabelValues()}"
-        selected="${ifDefined(this._computeLabelValue())}"
+        selected=${ifDefined(this._computeLabelValue())}
         @selected-item-changed=${this.setSelectedValueText}
         role="radiogroup"
         aria-labelledby="labelName"
@@ -244,22 +211,22 @@
       (value, index) => html`
         <gr-button
           role="radio"
-          title="${ifDefined(this.computeLabelValueTitle(value))}"
-          data-vote="${this._computeVoteAttribute(
+          title=${ifDefined(this.computeLabelValueTitle(value))}
+          data-vote=${this._computeVoteAttribute(
             Number(value),
             index,
             items.length
-          )}"
-          data-name="${ifDefined(this.label?.name)}"
-          data-value="${value}"
-          aria-label="${value}"
+          )}
+          data-name=${ifDefined(this.label?.name)}
+          data-value=${value}
+          aria-label=${value}
           voteChip
           flatten
         >
           <gr-tooltip-content
             has-tooltip
             light-tooltip
-            title="${ifDefined(this.computeLabelValueTitle(value))}"
+            title=${ifDefined(this.computeLabelValueTitle(value))}
           >
             ${value}
           </gr-tooltip-content>
@@ -270,13 +237,7 @@
 
   private renderSelectedValue() {
     return html`
-      <div
-        class="${classMap({
-          selectedValueCell: true,
-          hidden: !this._computeAnyPermittedLabelValues(),
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}"
-      >
+      <div class="selectedValueCell">
         <span id="selectedValueLabel">${this.selectedValueText}</span>
       </div>
     `;
@@ -305,25 +266,31 @@
   }
 
   // Private but used in tests.
-  _computeBlankItems(side: string) {
+  computeBlankItemsCount(side: string) {
     if (
       !this.label ||
       !this.permittedLabels?.[this.label.name] ||
       !this.permittedLabels[this.label.name].length ||
-      !this.labelValues ||
-      !Object.keys(this.labelValues).length
+      !this.orderedLabelValues?.length
     ) {
-      return [];
+      return 0;
     }
+    const orderedLabelValues = this.orderedLabelValues;
     const permittedLabel = this.permittedLabels[this.label.name];
-    const startPosition = this.labelValues[Number(permittedLabel[0])];
+    // How many empty cells need to be rendered to the left before showing
+    // the first value of the label range. If min value of the label is -1 and
+    // overall min possible is -2 then we render one empty cell. If overall min
+    // is -1 then we don't render any empty cell.
     if (side === 'start') {
-      return new Array(startPosition).fill('');
+      return Number(permittedLabel[0]) - orderedLabelValues[0];
     }
-    const endPosition =
-      this.labelValues[Number(permittedLabel[permittedLabel.length - 1])];
-    const length = Object.keys(this.labelValues).length - endPosition - 1;
-    return new Array(length).fill('');
+    // How many empty cells need to be rendered to the right after showing the
+    // last value of the label range. If max value is +1 and overall max value
+    // is +2 we add one empty cell to the right.
+    return (
+      orderedLabelValues[orderedLabelValues.length - 1] -
+      Number(permittedLabel[permittedLabel.length - 1])
+    );
   }
 
   private getLabelValue() {
@@ -419,15 +386,6 @@
     );
   };
 
-  _computeAnyPermittedLabelValues() {
-    return (
-      this.permittedLabels &&
-      this.label &&
-      hasOwnProperty(this.permittedLabels, this.label.name) &&
-      this.permittedLabels[this.label.name].length
-    );
-  }
-
   private computePermittedLabelValues() {
     if (!this.permittedLabels || !this.label) {
       return [];
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
index f12c8e6..24d4f9b 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
@@ -70,7 +70,8 @@
       Verified: ['-1', ' 0', '+1'],
     };
 
-    element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
+    element.orderedLabelValues = [-2, -1, 0, 1, 2];
+    //  {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
 
     element.label = {
       name: 'Verified',
@@ -180,26 +181,20 @@
     assert.strictEqual(element._computeLabelValue(), '+1');
   });
 
-  test('_computeBlankItems', () => {
-    element.labelValues = {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    };
+  test('computeBlankItemsCount', () => {
+    element.orderedLabelValues = [-2, -1, 0, 1, 2];
     element.label = {name: 'Code-Review', value: ' 0'};
-    assert.strictEqual(element._computeBlankItems('start').length, 0);
+    assert.strictEqual(element.computeBlankItemsCount('start'), 0);
 
     element.label = {name: 'Verified', value: ' 0'};
-    assert.strictEqual(element._computeBlankItems('start').length, 1);
+    assert.strictEqual(element.computeBlankItemsCount('start'), 1);
   });
 
   test('labelValues returns no keys', () => {
-    element.labelValues = {};
+    element.orderedLabelValues = [];
     element.label = {name: 'Code-Review', value: ' 0'};
 
-    assert.deepEqual(element._computeBlankItems('start'), []);
+    assert.deepEqual(element.computeBlankItemsCount('start'), 0);
   });
 
   test('changes in label score are reflected in the DOM', async () => {
@@ -243,25 +238,6 @@
     checkAriaCheckedValid();
   });
 
-  test('without permitted labels', async () => {
-    element.permittedLabels = {
-      Verified: ['-1', ' 0', '+1'],
-    };
-    await element.updateComplete;
-    assert.isOk(element.labelSelector);
-    assert.isFalse(element.labelSelector!.hidden);
-
-    element.permittedLabels = {};
-    await element.updateComplete;
-    assert.isOk(element.labelSelector);
-    assert.isTrue(element.labelSelector!.hidden);
-
-    element.permittedLabels = {Verified: []};
-    await element.updateComplete;
-    assert.isOk(element.labelSelector);
-    assert.isTrue(element.labelSelector!.hidden);
-  });
-
   test('asymmetrical labels', async () => {
     element.permittedLabels = {
       'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 35a2ba1..8e757da 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -18,34 +18,27 @@
 import '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
-  LabelNameToValueMap,
   ChangeInfo,
   AccountInfo,
-  DetailedLabelInfo,
-  LabelNameToInfoMap,
-  LabelNameToValuesMap,
+  LabelNameToValueMap,
 } from '../../../types/common';
-import {
-  GrLabelScoreRow,
-  Label,
-  LabelValuesMap,
-} from '../gr-label-score-row/gr-label-score-row';
-import {getAppContext} from '../../../services/app-context';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {
   getTriggerVotes,
-  labelCompare,
-  showNewSubmitRequirements,
+  computeLabels,
+  Label,
+  computeOrderedLabelValues,
+  getDefaultValue,
 } from '../../../utils/label-util';
-import {Execution} from '../../../constants/reporting';
 import {ChangeStatus} from '../../../constants/constants';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {LabelNameToValuesMap} from '../../../api/rest-api';
 
 @customElement('gr-label-scores')
 export class GrLabelScores extends LitElement {
   @property({type: Object})
-  permittedLabels?: LabelNameToValueMap;
+  permittedLabels?: LabelNameToValuesMap;
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -53,10 +46,6 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  private readonly reporting = getAppContext().reportingService;
-
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       fontStyles,
@@ -64,8 +53,6 @@
         .scoresTable {
           display: table;
           width: 100%;
-        }
-        .scoresTable.newSubmitRequirements {
           table-layout: fixed;
         }
         .mergedMessage,
@@ -77,7 +64,7 @@
         .permissionMessage {
           width: 100%;
           color: var(--deemphasized-text-color);
-          padding-left: var(--spacing-xl);
+          padding-left: var(--label-score-padding-left, 0);
         }
         gr-label-score-row:hover {
           background-color: var(--hover-background-color);
@@ -85,15 +72,12 @@
         gr-label-score-row {
           display: table-row;
         }
-        gr-label-score-row.no-access {
-          display: none;
-        }
-        .heading-3 {
-          padding-left: var(--spacing-xl);
-          margin-bottom: var(--spacing-m);
+        .heading-4 {
+          padding-left: var(--label-score-padding-left, 0);
+          margin-bottom: var(--spacing-s);
           margin-top: var(--spacing-l);
         }
-        .heading-3:first-of-type {
+        .heading-4:first-of-type {
           margin-top: 0;
         }
       `,
@@ -101,26 +85,13 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderOldSubmitRequirements() {
-    const labels = this._computeLabels();
-    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
-  }
-
-  private renderNewSubmitRequirements() {
     return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
     ${this.renderErrorMessages()}`;
   }
 
   private renderSubmitReqsLabels() {
     const triggerVotes = getTriggerVotes(this.change);
-    const labels = this._computeLabels().filter(
+    const labels = computeLabels(this.account, this.change).filter(
       label => !triggerVotes.includes(label.name)
     );
     if (!labels.length) return;
@@ -129,16 +100,16 @@
         label => !this.permittedLabels || this.permittedLabels[label.name]
       ).length === 0
     ) {
-      return html`<h3 class="heading-3">Submit requirements votes</h3>
+      return html`<h3 class="heading-4">Submit requirements votes</h3>
         <div class="permissionMessage">You don't have permission to vote</div>`;
     }
-    return html`<h3 class="heading-3">Submit requirements votes</h3>
+    return html`<h3 class="heading-4">Submit requirements votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
   private renderTriggerVotes() {
     const triggerVotes = getTriggerVotes(this.change);
-    const labels = this._computeLabels().filter(label =>
+    const labels = computeLabels(this.account, this.change).filter(label =>
       triggerVotes.includes(label.name)
     );
     if (!labels.length) return;
@@ -147,32 +118,32 @@
         label => !this.permittedLabels || this.permittedLabels[label.name]
       ).length === 0
     ) {
-      return html`<h3 class="heading-3">Trigger Votes</h3>
+      return html`<h3 class="heading-4">Trigger Votes</h3>
         <div class="permissionMessage">You don't have permission to vote</div>`;
     }
-    return html`<h3 class="heading-3">Trigger Votes</h3>
+    return html`<h3 class="heading-4">Trigger Votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
   private renderLabels(labels: Label[]) {
-    const newSubReqs = showNewSubmitRequirements(
-      this.flagsService,
-      this.change
-    );
-    const labelValues = this._computeColumns();
-    return html`<div
-      class="scoresTable ${newSubReqs ? 'newSubmitRequirements' : ''}"
-    >
-      ${labels.map(
-        label => html`<gr-label-score-row
-          class="${this.computeLabelAccessClass(label.name)}"
-          .label="${label}"
-          .name="${label.name}"
-          .labels="${this.change?.labels}"
-          .permittedLabels="${this.permittedLabels}"
-          .labelValues="${labelValues}"
-        ></gr-label-score-row>`
-      )}
+    return html`<div class="scoresTable">
+      ${labels
+        .filter(
+          label =>
+            this.permittedLabels?.[label.name] &&
+            this.permittedLabels?.[label.name].length > 0
+        )
+        .map(
+          label => html`<gr-label-score-row
+            .label=${label}
+            .name=${label.name}
+            .labels=${this.change?.labels}
+            .permittedLabels=${this.permittedLabels}
+            .orderedLabelValues=${computeOrderedLabelValues(
+              this.permittedLabels
+            )}
+          ></gr-label-score-row>`
+        )}
     </div>`;
   }
 
@@ -191,15 +162,15 @@
       </div>`;
   }
 
-  getLabelValues(includeDefaults = true): LabelNameToValuesMap {
-    const labels: LabelNameToValuesMap = {};
+  getLabelValues(includeDefaults = true): LabelNameToValueMap {
+    const labels: LabelNameToValueMap = {};
     if (this.shadowRoot === null || !this.change) {
       return labels;
     }
     for (const label of Object.keys(this.permittedLabels ?? {})) {
-      const selectorEl = this.shadowRoot.querySelector(
+      const selectorEl = this.shadowRoot.querySelector<GrLabelScoreRow>(
         `gr-label-score-row[name="${label}"]`
-      ) as null | GrLabelScoreRow;
+      );
       if (!selectorEl?.selectedItem) continue;
 
       const selectedVal =
@@ -209,106 +180,13 @@
 
       if (selectedVal === undefined) continue;
 
-      const defValNum = this.getDefaultValue(label);
+      const defValNum = getDefaultValue(this.change?.labels, label);
       if (includeDefaults || selectedVal !== defValNum) {
         labels[label] = selectedVal;
       }
     }
     return labels;
   }
-
-  private getStringLabelValue(
-    labels: LabelNameToInfoMap,
-    labelName: string,
-    numberValue?: number
-  ): string {
-    const detailedInfo = labels[labelName] as DetailedLabelInfo;
-    if (detailedInfo.values) {
-      for (const labelValue of Object.keys(detailedInfo.values)) {
-        if (Number(labelValue) === numberValue) {
-          return labelValue;
-        }
-      }
-    }
-    const stringVal = `${numberValue}`;
-    this.reporting.reportExecution(Execution.REACHABLE_CODE, {
-      value: stringVal,
-      id: 'label-value-not-found',
-    });
-    return stringVal;
-  }
-
-  private getDefaultValue(labelName?: string) {
-    const labels = this.change?.labels;
-    if (!labelName || !labels?.[labelName]) return undefined;
-    const labelInfo = labels[labelName] as DetailedLabelInfo;
-    return labelInfo.default_value;
-  }
-
-  _getVoteForAccount(labelName: string): string | null {
-    const labels = this.change?.labels;
-    if (!labels) return null;
-    const votes = labels[labelName] as DetailedLabelInfo;
-    if (votes.all && votes.all.length > 0) {
-      for (let i = 0; i < votes.all.length; i++) {
-        if (
-          this.account &&
-          // TODO(TS): Replace == with === and check code can assign string to _account_id instead of number
-          // eslint-disable-next-line eqeqeq
-          votes.all[i]._account_id == this.account._account_id
-        ) {
-          return this.getStringLabelValue(
-            labels,
-            labelName,
-            votes.all[i].value
-          );
-        }
-      }
-    }
-    return null;
-  }
-
-  _computeLabels(): Label[] {
-    if (!this.account) return [];
-    const labelsObj = this.change?.labels;
-    if (!labelsObj) return [];
-    return Object.keys(labelsObj)
-      .sort(labelCompare)
-      .map(key => {
-        return {
-          name: key,
-          value: this._getVoteForAccount(key),
-        };
-      });
-  }
-
-  _computeColumns() {
-    if (!this.permittedLabels) return;
-    const labels = Object.keys(this.permittedLabels);
-    const values: Set<number> = new Set();
-    for (const label of labels) {
-      for (const value of this.permittedLabels[label]) {
-        values.add(Number(value));
-      }
-    }
-
-    const orderedValues = Array.from(values.values()).sort((a, b) => a - b);
-
-    const labelValues: LabelValuesMap = {};
-    for (let i = 0; i < orderedValues.length; i++) {
-      labelValues[orderedValues[i]] = i;
-    }
-    return labelValues;
-  }
-
-  private computeLabelAccessClass(label?: string) {
-    if (!this.permittedLabels || !label) return '';
-
-    return hasOwnProperty(this.permittedLabels, label) &&
-      this.permittedLabels[label].length
-      ? 'access'
-      : 'no-access';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
index 0310424..2751163 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -26,6 +26,7 @@
   createChange,
 } from '../../../test/test-data-generators';
 import {ChangeStatus} from '../../../constants/constants';
+import {getVoteForAccount} from '../../../utils/label-util';
 
 const basicFixture = fixtureFromElement('gr-label-scores');
 
@@ -116,77 +117,14 @@
     assert.deepEqual(element.getLabelValues(false), {});
   });
 
-  test('_getVoteForAccount', () => {
+  test('getVoteForAccount', () => {
     const labelName = 'Code-Review';
-    assert.strictEqual(element._getVoteForAccount(labelName), '+1');
+    assert.strictEqual(
+      getVoteForAccount(labelName, element.account, element.change),
+      '+1'
+    );
   });
 
-  test('_computeColumns', () => {
-    const labelValues = element._computeColumns();
-    assert.deepEqual(labelValues, {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    });
-  });
-
-  test('changes in label score are reflected in _labels', async () => {
-    const change = {
-      ...createChange(),
-      labels: {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-        Verified: {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-      },
-    };
-    element.change = change;
-    await flush();
-    let labels = element._computeLabels();
-    assert.deepEqual(labels, [
-      {name: 'Code-Review', value: null},
-      {name: 'Verified', value: null},
-    ]);
-    element.change = {
-      ...change,
-      labels: {
-        ...change.labels,
-        Verified: {
-          ...change.labels.Verified,
-          all: [
-            {
-              _account_id: accountId,
-              value: 1,
-            },
-          ],
-        },
-      },
-    };
-    await flush();
-    labels = element._computeLabels();
-    assert.deepEqual(labels, [
-      {name: 'Code-Review', value: null},
-      {name: 'Verified', value: '+1'},
-    ]);
-  });
   suite('message', () => {
     test('shown when change is abandoned', async () => {
       element.change = {
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 50492d2..f204d76 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -25,8 +25,6 @@
 } from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {getTriggerVotes} from '../../../utils/label-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 const VOTE_RESET_TEXT = '0 (vote reset)';
 
@@ -102,8 +100,6 @@
     `;
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
   override render() {
     const scores = this._getScores(this.message, this.labelExtremes);
     const triggerVotes = getTriggerVotes(this.change);
@@ -112,18 +108,17 @@
 
   private renderScore(score: Score, triggerVotes: string[]) {
     if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
       score.label &&
       triggerVotes.includes(score.label) &&
       !score.value?.includes(VOTE_RESET_TEXT)
     ) {
       const labels = this.change?.labels ?? {};
       return html`<gr-trigger-vote
-        .label="${score.label}"
+        .label=${score.label}
         .displayValue=${score.value}
-        .labelInfo="${labels[score.label]}"
-        .change="${this.change}"
-        .mutable="${false}"
+        .labelInfo=${labels[score.label]}
+        .change=${this.change}
+        .mutable=${false}
         disable-hovercards
       >
       </gr-trigger-vote>`;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 8664de3..0902fe6 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -21,12 +21,10 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../../styles/shared-styles';
 import '../gr-message-scores/gr-message-scores';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-message_html';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {MessageTag, SpecialFilePath} from '../../../constants/constants';
-import {customElement, property, computed, observe} from '@polymer/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {
   ChangeInfo,
   ServerInfo,
@@ -42,6 +40,7 @@
 import {
   ChangeMessage,
   CommentThread,
+  isFormattedReviewerUpdate,
   LabelExtreme,
   PATCH_SET_PREFIX_PATTERN,
 } from '../../../utils/comment-util';
@@ -54,6 +53,9 @@
   computePredecessor,
 } from '../../../utils/patch-set-util';
 import {isServiceUser, replaceTemplates} from '../../../utils/account-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {when} from 'lit/directives/when';
+import {FormattedReviewerUpdateInfo} from '../../../types/types';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -68,11 +70,7 @@
 }
 
 @customElement('gr-message')
-export class GrMessage extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrMessage extends LitElement {
   /**
    * Fired when this message's reply link is tapped.
    *
@@ -98,12 +96,11 @@
   changeNum?: NumericChangeId;
 
   @property({type: Object})
-  message: ChangeMessage | undefined;
+  message?: ChangeMessage | (ChangeMessage & FormattedReviewerUpdateInfo);
 
   @property({type: Array})
   commentThreads: CommentThread[] = [];
 
-  @computed('message')
   get author() {
     return this.message?.author || this.message?.updated_by;
   }
@@ -114,31 +111,8 @@
   @property({type: Boolean})
   hideAutomated = false;
 
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-    computed: '_computeIsHidden(hideAutomated, isAutomated)',
-  })
-  override hidden = false;
-
-  @computed('message')
-  get isAutomated() {
-    return !!this.message && this._computeIsAutomated(this.message);
-  }
-
-  @computed('message')
-  get showOnBehalfOf() {
-    return !!this.message && this._computeShowOnBehalfOf(this.message);
-  }
-
-  @property({
-    type: Boolean,
-    computed: '_computeShowReplyButton(message, _loggedIn)',
-  })
-  showReplyButton = false;
-
   @property({type: String})
-  projectName?: string;
+  projectName?: RepoName;
 
   /**
    * A mapping from label names to objects representing the minimum and
@@ -147,51 +121,23 @@
   @property({type: Object})
   labelExtremes?: LabelExtreme;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  @state()
+  private projectConfig?: ConfigInfo;
 
   @property({type: Boolean})
-  _loggedIn = false;
+  loggedIn = false;
 
-  @property({type: Boolean})
-  _isAdmin = false;
+  @state()
+  private isAdmin = false;
 
-  @property({type: Boolean})
-  _isDeletingChangeMsg = false;
-
-  @property({type: Boolean, computed: '_computeExpanded(message.expanded)'})
-  _expanded = false;
-
-  @property({
-    type: String,
-    computed:
-      '_computeMessageContentExpanded(_expanded, message.message,' +
-      ' message.accounts_in_message,' +
-      ' message.tag)',
-  })
-  _messageContentExpanded = '';
-
-  @property({
-    type: String,
-    computed:
-      '_computeMessageContentCollapsed(message.message,' +
-      ' message.accounts_in_message,' +
-      ' message.tag,' +
-      ' commentThreads)',
-  })
-  _messageContentCollapsed = '';
-
-  @property({
-    type: String,
-    computed: '_computeCommentCountText(commentThreads)',
-  })
-  _commentCountText = '';
+  @state()
+  private isDeletingChangeMsg = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this.addEventListener('click', e => this._handleClick(e));
+    this.addEventListener('click', e => this.handleClick(e));
   }
 
   override connectedCallback() {
@@ -200,44 +146,380 @@
       this.config = config;
     });
     this.restApiService.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+      this.loggedIn = loggedIn;
     });
     this.restApiService.getIsAdmin().then(isAdmin => {
-      this._isAdmin = !!isAdmin;
+      this.isAdmin = !!isAdmin;
     });
   }
 
-  @observe('message.expanded')
-  _updateExpandedClass(expanded: boolean) {
-    if (expanded) {
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+        position: relative;
+        cursor: pointer;
+        overflow-y: hidden;
+      }
+      :host(.expanded) {
+        cursor: auto;
+      }
+      .collapsed .contentContainer {
+        align-items: center;
+        color: var(--deemphasized-text-color);
+        display: flex;
+        white-space: nowrap;
+      }
+      .contentContainer {
+        padding: var(--spacing-m) var(--spacing-l);
+      }
+      .expanded .contentContainer {
+        background-color: var(--background-color-secondary);
+      }
+      .collapsed .contentContainer {
+        background-color: var(--background-color-primary);
+      }
+      div.serviceUser.expanded div.contentContainer {
+        background-color: var(
+          --background-color-service-user,
+          var(--background-color-secondary)
+        );
+      }
+      div.serviceUser.collapsed div.contentContainer {
+        background-color: var(
+          --background-color-service-user,
+          var(--background-color-primary)
+        );
+      }
+      .name {
+        font-weight: var(--font-weight-bold);
+      }
+      .message {
+        --gr-formatted-text-prose-max-width: 120ch;
+      }
+      .collapsed .message {
+        max-width: none;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .collapsed .author,
+      .collapsed .content,
+      .collapsed .message,
+      .collapsed .updateCategory,
+      gr-account-chip {
+        display: inline;
+      }
+      gr-button {
+        margin: 0 -4px;
+      }
+      .collapsed gr-thread-list,
+      .collapsed .replyBtn,
+      .collapsed .deleteBtn,
+      .collapsed .hideOnCollapsed,
+      .hideOnOpen {
+        display: none;
+      }
+      .replyBtn {
+        margin-right: var(--spacing-m);
+      }
+      .collapsed .hideOnOpen {
+        display: block;
+      }
+      .collapsed .content {
+        flex: 1;
+        margin-right: var(--spacing-m);
+        min-width: 0;
+        overflow: hidden;
+      }
+      .collapsed .content.messageContent {
+        text-overflow: ellipsis;
+      }
+      .collapsed .dateContainer {
+        position: static;
+      }
+      .collapsed .author {
+        overflow: hidden;
+        color: var(--primary-text-color);
+        margin-right: var(--spacing-s);
+      }
+      .authorLabel {
+        min-width: 130px;
+        --account-max-length: 120px;
+        margin-right: var(--spacing-s);
+      }
+      .expanded .author {
+        cursor: pointer;
+        margin-bottom: var(--spacing-m);
+      }
+      .expanded .content {
+        padding-left: 40px;
+      }
+      .dateContainer {
+        position: absolute;
+        /* right and top values should match .contentContainer padding */
+        right: var(--spacing-l);
+        top: var(--spacing-m);
+      }
+      .dateContainer gr-button {
+        margin-right: var(--spacing-m);
+        color: var(--deemphasized-text-color);
+      }
+      .dateContainer .patchset:before {
+        content: 'Patchset ';
+      }
+      .dateContainer .patchsetDiffButton {
+        margin-right: var(--spacing-m);
+        --gr-button-padding: 0 var(--spacing-m);
+      }
+      span.date {
+        color: var(--deemphasized-text-color);
+      }
+      span.date:hover {
+        text-decoration: underline;
+      }
+      .dateContainer iron-icon {
+        cursor: pointer;
+        vertical-align: top;
+      }
+      .commentsSummary {
+        margin-right: var(--spacing-s);
+        min-width: 115px;
+      }
+      .expanded .commentsSummary {
+        display: none;
+      }
+      .commentsIcon {
+        vertical-align: top;
+      }
+      gr-account-label::part(gr-account-label-text) {
+        font-weight: var(--font-weight-bold);
+      }
+      iron-icon {
+        --iron-icon-height: 20px;
+        --iron-icon-width: 20px;
+      }
+      @media screen and (max-width: 50em) {
+        .expanded .content {
+          padding-left: 0;
+        }
+        .commentsSummary {
+          min-width: 0px;
+        }
+        .authorLabel {
+          width: 100px;
+        }
+        .dateContainer .patchset:before {
+          content: 'PS ';
+        }
+      }
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('projectName')) {
+      this.projectNameChanged();
+    }
+  }
+
+  override render() {
+    if (!this.message) return nothing;
+    if (this.hideAutomated && this.computeIsAutomated()) return nothing;
+    this.updateExpandedClass();
+    return html` <div class=${this.computeClass()}>
+      <div class="contentContainer">
+        ${this.renderAuthor()} ${this.renderCommentsSummary()}
+        ${this.renderMessageContent()} ${this.renderReviewerUpdate()}
+        ${this.renderDateContainer()}
+      </div>
+    </div>`;
+  }
+
+  private renderAuthor() {
+    assertIsDefined(this.message, 'message');
+    return html` <div class="author" @click=${this.handleAuthorClick}>
+      ${when(
+        this.computeShowOnBehalfOf(),
+        () => html`
+          <span>
+            <span class="name">${this.message?.real_author?.name}</span>
+            on behalf of
+          </span>
+        `
+      )}
+      <gr-account-label
+        .account=${this.author}
+        class="authorLabel"
+      ></gr-account-label>
+      <gr-message-scores
+        .labelExtremes=${this.labelExtremes}
+        .message=${this.message}
+        .change=${this.change}
+      ></gr-message-scores>
+    </div>`;
+  }
+
+  private renderCommentsSummary() {
+    if (!this.commentThreads?.length) return nothing;
+
+    const commentCountText = pluralize(this.commentThreads.length, 'comment');
+    return html`
+      <div class="commentsSummary">
+        <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
+        <span class="numberOfComments">${commentCountText}</span>
+      </div>
+    `;
+  }
+
+  private renderMessageContent() {
+    if (!this.message?.message) return nothing;
+    const messageContentCollapsed =
+      this.computeMessageContent(
+        false,
+        this.message.message.substring(0, 1000),
+        this.message.accounts_in_message,
+        this.message.tag
+      ) || this.patchsetCommentSummary();
+    return html` <div class="content messageContent">
+      <div class="message hideOnOpen">${messageContentCollapsed}</div>
+      ${this.renderExpandedMessageContent()}
+    </div>`;
+  }
+
+  private renderExpandedMessageContent() {
+    if (!this.message?.expanded) return nothing;
+    const messageContentExpanded = this.computeMessageContent(
+      true,
+      this.message.message,
+      this.message.accounts_in_message,
+      this.message.tag
+    );
+    return html`
+      <gr-formatted-text
+        noTrailingMargin
+        class="message hideOnCollapsed"
+        .content=${messageContentExpanded}
+        .config=${this.projectConfig?.commentlinks}
+      ></gr-formatted-text>
+      ${when(messageContentExpanded, () => this.renderActionContainer())}
+      <gr-thread-list
+        ?hidden=${!this.commentThreads.length}
+        .threads=${this.commentThreads}
+        hide-dropdown
+        show-comment-context
+        .messageId=${this.message.id}
+      >
+      </gr-thread-list>
+    `;
+  }
+
+  private renderActionContainer() {
+    if (!this.computeShowReplyButton()) return nothing;
+    return html` <div class="replyActionContainer">
+      <gr-button class="replyBtn" link="" @click=${this.handleReplyTap}>
+        Reply
+      </gr-button>
+      ${when(
+        this.isAdmin,
+        () => html`
+          <gr-button
+            ?disabled=${this.isDeletingChangeMsg}
+            class="deleteBtn"
+            link=""
+            @click=${this.handleDeleteMessage}
+          >
+            Delete
+          </gr-button>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderReviewerUpdate() {
+    assertIsDefined(this.message, 'message');
+    if (!isFormattedReviewerUpdate(this.message)) return;
+    return html` <div class="content">
+      ${this.message.updates.map(update => this.renderMessageUpdate(update))}
+    </div>`;
+  }
+
+  private renderMessageUpdate(update: {
+    message: string;
+    reviewers: AccountInfo[];
+  }) {
+    return html`<div class="updateCategory">
+      ${update.message}
+      ${update.reviewers.map(
+        reviewer => html`
+          <gr-account-chip .account=${reviewer} .change=${this.change}>
+          </gr-account-chip>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderDateContainer() {
+    return html`<span class="dateContainer">
+      ${this.renderDiffButton()}
+      ${when(
+        this.message?._revision_number,
+        () => html`
+          <span class="patchset">${this.message?._revision_number} |</span>
+        `
+      )}
+      ${when(
+        this.message?.id,
+        () => html`
+          <span class="date" @click=${this.handleAnchorClick}>
+            <gr-date-formatter
+              withTooltip
+              showDateAndTime
+              .dateStr=${this.message?.date}
+            ></gr-date-formatter>
+          </span>
+        `,
+        () => html`
+          <span class="date">
+            <gr-date-formatter
+              withTooltip
+              showDateAndTime
+              .dateStr=${this.message?.date}
+            ></gr-date-formatter>
+          </span>
+        `
+      )}
+      <iron-icon
+        id="expandToggle"
+        @click=${this.toggleExpanded}
+        title="Toggle expanded state"
+        icon=${this.computeExpandToggleIcon()}
+      ></iron-icon>
+    </span>`;
+  }
+
+  private renderDiffButton() {
+    if (!this.showViewDiffButton()) return nothing;
+    return html` <gr-button
+      class="patchsetDiffButton"
+      @click=${this.handleViewPatchsetDiff}
+      link
+    >
+      View Diff
+    </gr-button>`;
+  }
+
+  private updateExpandedClass() {
+    if (this.message?.expanded) {
       this.classList.add('expanded');
     } else {
       this.classList.remove('expanded');
     }
   }
 
-  _computeCommentCountText(commentThreads?: CommentThread[]) {
-    if (!commentThreads?.length) {
-      return undefined;
-    }
-
-    return pluralize(commentThreads.length, 'comment');
-  }
-
-  _computeMessageContentExpanded(
-    expanded: boolean,
-    content?: string,
-    accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag
-  ) {
-    if (!expanded) return '';
-    return this._computeMessageContent(true, content, accountsInMessage, tag);
-  }
-
-  _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
+  // Private but used in tests.
+  patchsetCommentSummary() {
     const id = this.message?.id;
     if (!id) return '';
-    const patchsetThreads = commentThreads.filter(
+    const patchsetThreads = (this.commentThreads ?? []).filter(
       thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
     );
     for (const thread of patchsetThreads) {
@@ -258,45 +540,29 @@
     return '';
   }
 
-  _computeMessageContentCollapsed(
-    content?: string,
-    accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag,
-    commentThreads?: CommentThread[]
-  ) {
-    // Content is under text-overflow, so it's always shorten
-    const shortenedContent = content?.substring(0, 1000);
-    const summary = this._computeMessageContent(
-      false,
-      shortenedContent,
-      accountsInMessage,
-      tag
-    );
-    if (summary || !commentThreads) return summary;
-    return this._patchsetCommentSummary(commentThreads);
-  }
-
-  _showViewDiffButton(message?: ChangeMessage) {
+  private showViewDiffButton() {
     return (
-      this._isNewPatchsetTag(message?.tag) || this._isMergePatchset(message)
+      this.isNewPatchsetTag(this.message?.tag) ||
+      this.isMergePatchset(this.message)
     );
   }
 
-  _isMergePatchset(message?: ChangeMessage) {
+  private isMergePatchset(message?: ChangeMessage) {
     return (
       message?.tag === MessageTag.TAG_MERGED &&
       message?.message.match(MERGED_PATCHSET_PATTERN)
     );
   }
 
-  _isNewPatchsetTag(tag?: ReviewInputTag) {
+  private isNewPatchsetTag(tag?: ReviewInputTag) {
     return (
       tag === MessageTag.TAG_NEW_PATCHSET ||
       tag === MessageTag.TAG_NEW_WIP_PATCHSET
     );
   }
 
-  _handleViewPatchsetDiff(e: Event) {
+  // Private but used in tests
+  handleViewPatchsetDiff(e: Event) {
     if (!this.message || !this.change) return;
     let patchNum: PatchSetNum;
     let basePatchNum: PatchSetNum;
@@ -323,14 +589,15 @@
     e.stopPropagation();
   }
 
-  _computeMessageContent(
+  // private but used in tests
+  computeMessageContent(
     isExpanded: boolean,
     content?: string,
     accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag
   ) {
     if (!content) return '';
-    const isNewPatchSet = this._isNewPatchsetTag(tag);
+    const isNewPatchSet = this.isNewPatchsetTag(tag);
 
     if (accountsInMessage) {
       content = replaceTemplates(content, accountsInMessage, this.config);
@@ -371,74 +638,68 @@
     return mappedLines.join('\n').trim();
   }
 
-  _computeAuthor(message: ChangeMessage) {
-    return message.author || message.updated_by;
-  }
-
-  _computeShowOnBehalfOf(message: ChangeMessage) {
-    const author = this._computeAuthor(message);
+  // private but used in tests
+  computeShowOnBehalfOf() {
+    if (!this.message) return false;
     return !!(
-      author &&
-      message.real_author &&
-      author._account_id !== message.real_author._account_id
+      this.author &&
+      this.message.real_author &&
+      this.author._account_id !== this.message.real_author._account_id
     );
   }
 
-  _computeShowReplyButton(message?: ChangeMessage, loggedIn?: boolean) {
+  // private but used in tests.
+  computeShowReplyButton() {
     return (
-      message &&
-      !!message.message &&
-      loggedIn &&
-      !this._computeIsAutomated(message)
+      !!this.message &&
+      !!this.message.message &&
+      this.loggedIn &&
+      !this.computeIsAutomated()
     );
   }
 
-  _computeExpanded(expanded: boolean) {
-    return expanded;
-  }
-
-  _handleClick(e: Event) {
-    if (this.message?.expanded) {
+  private handleClick(e: Event) {
+    if (!this.message || this.message?.expanded) {
       return;
     }
     e.stopPropagation();
-    this.set('message.expanded', true);
+    this.message.expanded = true;
+    this.requestUpdate();
   }
 
-  _handleAuthorClick(e: Event) {
-    if (!this.message?.expanded) {
+  private handleAuthorClick(e: Event) {
+    if (!this.message || !this.message?.expanded) {
       return;
     }
     e.stopPropagation();
-    this.set('message.expanded', false);
+    this.message.expanded = false;
+    this.requestUpdate();
   }
 
-  _computeIsAutomated(message: ChangeMessage) {
+  // private but used in tests.
+  computeIsAutomated() {
     return !!(
-      message.reviewer ||
-      this._computeIsReviewerUpdate(message) ||
-      (message.tag && message.tag.startsWith('autogenerated'))
+      this.message?.reviewer ||
+      this.computeIsReviewerUpdate() ||
+      (this.message?.tag && this.message.tag.startsWith('autogenerated'))
     );
   }
 
-  _computeIsHidden(hideAutomated: boolean, isAutomated: boolean) {
-    return hideAutomated && isAutomated;
+  private computeIsReviewerUpdate() {
+    return this.message?.type === 'REVIEWER_UPDATE';
   }
 
-  _computeIsReviewerUpdate(message: ChangeMessage) {
-    return message.type === 'REVIEWER_UPDATE';
-  }
-
-  _computeClass(expanded?: boolean, author?: AccountInfo) {
+  private computeClass() {
+    const expanded = this.message?.expanded;
     const classes = [];
     classes.push(expanded ? 'expanded' : 'collapsed');
-    if (isServiceUser(author)) classes.push('serviceUser');
+    if (isServiceUser(this.author)) classes.push('serviceUser');
     return classes.join(' ');
   }
 
-  _handleAnchorClick(e: Event) {
+  private handleAnchorClick(e: Event) {
     e.preventDefault();
-    // The element which triggers _handleAnchorClick is rendered only if
+    // The element which triggers handleAnchorClick is rendered only if
     // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
@@ -452,7 +713,7 @@
     );
   }
 
-  _handleReplyTap(e: Event) {
+  private handleReplyTap(e: Event) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('reply', {
@@ -463,14 +724,14 @@
     );
   }
 
-  _handleDeleteMessage(e: Event) {
+  private handleDeleteMessage(e: Event) {
     e.preventDefault();
     if (!this.message || !this.message.id || !this.changeNum) return;
-    this._isDeletingChangeMsg = true;
+    this.isDeletingChangeMsg = true;
     this.restApiService
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
-        this._isDeletingChangeMsg = false;
+        this.isDeletingChangeMsg = false;
         this.dispatchEvent(
           new CustomEvent('change-message-deleted', {
             detail: {message: this.message},
@@ -481,23 +742,25 @@
       });
   }
 
-  @observe('projectName')
-  _projectNameChanged(name?: string) {
-    if (!name) {
-      this._projectConfig = undefined;
+  private projectNameChanged() {
+    if (!this.projectName) {
+      this.projectConfig = undefined;
       return;
     }
-    this.restApiService.getProjectConfig(name as RepoName).then(config => {
-      this._projectConfig = config;
+    this.restApiService.getProjectConfig(this.projectName).then(config => {
+      this.projectConfig = config;
     });
   }
 
-  _computeExpandToggleIcon(expanded: boolean) {
-    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+  private computeExpandToggleIcon() {
+    return this.message?.expanded
+      ? 'gr-icons:expand-less'
+      : 'gr-icons:expand-more';
   }
 
-  _toggleExpanded(e: Event) {
+  private toggleExpanded(e: Event) {
     e.stopPropagation();
-    this.set('message.expanded', !this.message?.expanded);
+    if (!this.message) return;
+    this.message = {...this.message, expanded: !this.message.expanded};
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
deleted file mode 100644
index 70e6381..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ /dev/null
@@ -1,307 +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>
-    :host {
-      display: block;
-      position: relative;
-      cursor: pointer;
-      overflow-y: hidden;
-    }
-    :host(.expanded) {
-      cursor: auto;
-    }
-    .collapsed .contentContainer {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-      display: flex;
-      white-space: nowrap;
-    }
-    .contentContainer {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .expanded .contentContainer {
-      background-color: var(--background-color-secondary);
-    }
-    .collapsed .contentContainer {
-      background-color: var(--background-color-primary);
-    }
-    div.serviceUser.expanded div.contentContainer {
-      background-color: var(
-        --background-color-service-user,
-        var(--background-color-secondary)
-      );
-    }
-    div.serviceUser.collapsed div.contentContainer {
-      background-color: var(
-        --background-color-service-user,
-        var(--background-color-primary)
-      );
-    }
-    .name {
-      font-weight: var(--font-weight-bold);
-    }
-    .message {
-      --gr-formatted-text-prose-max-width: 120ch;
-    }
-    .collapsed .message {
-      max-width: none;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-    .collapsed .author,
-    .collapsed .content,
-    .collapsed .message,
-    .collapsed .updateCategory,
-    gr-account-chip {
-      display: inline;
-    }
-    gr-button {
-      margin: 0 -4px;
-    }
-    .collapsed gr-thread-list,
-    .collapsed .replyBtn,
-    .collapsed .deleteBtn,
-    .collapsed .hideOnCollapsed,
-    .hideOnOpen {
-      display: none;
-    }
-    .replyBtn {
-      margin-right: var(--spacing-m);
-    }
-    .collapsed .hideOnOpen {
-      display: block;
-    }
-    .collapsed .content {
-      flex: 1;
-      margin-right: var(--spacing-m);
-      min-width: 0;
-      overflow: hidden;
-    }
-    .collapsed .content.messageContent {
-      text-overflow: ellipsis;
-    }
-    .collapsed .dateContainer {
-      position: static;
-    }
-    .collapsed .author {
-      overflow: hidden;
-      color: var(--primary-text-color);
-      margin-right: var(--spacing-s);
-    }
-    .authorLabel {
-      min-width: 130px;
-      --account-max-length: 120px;
-      margin-right: var(--spacing-s);
-    }
-    .expanded .author {
-      cursor: pointer;
-      margin-bottom: var(--spacing-m);
-    }
-    .expanded .content {
-      padding-left: 40px;
-    }
-    .dateContainer {
-      position: absolute;
-      /* right and top values should match .contentContainer padding */
-      right: var(--spacing-l);
-      top: var(--spacing-m);
-    }
-    .dateContainer gr-button {
-      margin-right: var(--spacing-m);
-      color: var(--deemphasized-text-color);
-    }
-    .dateContainer .patchset:before {
-      content: 'Patchset ';
-    }
-    .dateContainer .patchsetDiffButton {
-      margin-right: var(--spacing-m);
-      --gr-button-padding: 0 var(--spacing-m);
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .dateContainer iron-icon {
-      cursor: pointer;
-      vertical-align: top;
-    }
-    .commentsSummary {
-      margin-right: var(--spacing-s);
-      min-width: 115px;
-    }
-    .expanded .commentsSummary {
-      display: none;
-    }
-    .commentsIcon {
-      vertical-align: top;
-    }
-    gr-account-label::part(gr-account-label-text) {
-      font-weight: var(--font-weight-bold);
-    }
-    iron-icon {
-      --iron-icon-height: 20px;
-      --iron-icon-width: 20px;
-    }
-    @media screen and (max-width: 50em) {
-      .expanded .content {
-        padding-left: 0;
-      }
-      .commentsSummary {
-        min-width: 0px;
-      }
-      .authorLabel {
-        width: 100px;
-      }
-      .dateContainer .patchset:before {
-        content: 'PS ';
-      }
-    }
-  </style>
-  <div class$="[[_computeClass(_expanded, author)]]">
-    <div class="contentContainer">
-      <div class="author" on-click="_handleAuthorClick">
-        <span hidden$="[[!showOnBehalfOf]]">
-          <span class="name">[[message.real_author.name]]</span>
-          on behalf of
-        </span>
-        <gr-account-label
-          account="[[author]]"
-          class="authorLabel"
-        ></gr-account-label>
-        <gr-message-scores
-          label-extremes="[[labelExtremes]]"
-          message="[[message]]"
-          change="[[change]]"
-        ></gr-message-scores>
-      </div>
-      <template is="dom-if" if="[[_commentCountText]]">
-        <div class="commentsSummary">
-          <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
-          <span class="numberOfComments">[[_commentCountText]]</span>
-        </div>
-      </template>
-      <template is="dom-if" if="[[message.message]]">
-        <div class="content messageContent">
-          <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
-          <template is="dom-if" if="[[_expanded]]">
-            <gr-formatted-text
-              noTrailingMargin
-              class="message hideOnCollapsed"
-              content="[[_messageContentExpanded]]"
-              config="[[_projectConfig.commentlinks]]"
-            ></gr-formatted-text>
-            <template is="dom-if" if="[[_messageContentExpanded]]">
-              <div
-                class="replyActionContainer"
-                hidden$="[[!showReplyButton]]"
-                hidden=""
-              >
-                <gr-button
-                  class="replyBtn"
-                  link=""
-                  small=""
-                  on-click="_handleReplyTap"
-                >
-                  Reply
-                </gr-button>
-                <gr-button
-                  disabled$="[[_isDeletingChangeMsg]]"
-                  class="deleteBtn"
-                  hidden$="[[!_isAdmin]]"
-                  hidden=""
-                  link=""
-                  small=""
-                  on-click="_handleDeleteMessage"
-                >
-                  Delete
-                </gr-button>
-              </div>
-            </template>
-            <gr-thread-list
-              hidden$="[[!commentThreads.length]]"
-              threads="[[commentThreads]]"
-              hide-dropdown
-              show-comment-context
-              message-id="[[message.id]]"
-            >
-            </gr-thread-list>
-          </template>
-        </div>
-      </template>
-      <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
-        <div class="content">
-          <template is="dom-repeat" items="[[message.updates]]" as="update">
-            <div class="updateCategory">
-              [[update.message]]
-              <template
-                is="dom-repeat"
-                items="[[update.reviewers]]"
-                as="reviewer"
-              >
-                <gr-account-chip account="[[reviewer]]" change="[[change]]">
-                </gr-account-chip>
-              </template>
-            </div>
-          </template>
-        </div>
-      </template>
-      <span class="dateContainer">
-        <template is="dom-if" if="[[_showViewDiffButton(message)]]">
-          <gr-button
-            class="patchsetDiffButton"
-            on-click="_handleViewPatchsetDiff"
-            link
-          >
-            View Diff
-          </gr-button>
-        </template>
-        <template is="dom-if" if="[[message._revision_number]]">
-          <span class="patchset">[[message._revision_number]] |</span>
-        </template>
-        <template is="dom-if" if="[[!message.id]]">
-          <span class="date">
-            <gr-date-formatter
-              withTooltip
-              showDateAndTime
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <template is="dom-if" if="[[message.id]]">
-          <span class="date" on-click="_handleAnchorClick">
-            <gr-date-formatter
-              withTooltip
-              showDateAndTime
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <iron-icon
-          id="expandToggle"
-          on-click="_toggleExpanded"
-          title="Toggle expanded state"
-          icon="[[_computeExpandToggleIcon(_expanded)]]"
-        ></iron-icon>
-      </span>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 2acc2a8..bbe39ff 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -51,8 +51,8 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {CommentSide} from '../../../constants/constants';
 import {SinonStubbedMember} from 'sinon';
-
-const basicFixture = fixtureFromElement('gr-message');
+import {html} from 'lit';
+import {fixture} from '@open-wc/testing-helpers';
 
 suite('gr-message tests', () => {
   let element: GrMessage;
@@ -60,8 +60,7 @@
   suite('when admin and logged in', () => {
     setup(async () => {
       stubRestApi('getIsAdmin').returns(Promise.resolve(true));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
     test('reply event', async () => {
@@ -85,9 +84,7 @@
         promise.resolve();
       });
       await flush();
-      assert.isFalse(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
+      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
       tap(queryAndAssert(element, '.replyBtn'));
       await promise;
     });
@@ -106,9 +103,9 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
-      await flush();
-      assert.isFalse(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      assert.isOk(query<HTMLElement>(element, '.deleteBtn'));
     });
 
     test('delete change message', async () => {
@@ -126,96 +123,206 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
       const promise = mockPromise();
       element.addEventListener(
         'change-message-deleted',
-        (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+        async (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+          await element.updateComplete;
           assert.deepEqual(e.detail.message, element.message);
           assert.isFalse(
-            (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
+            queryAndAssert<GrButton>(element, '.deleteBtn').disabled
           );
           promise.resolve();
         }
       );
-      await flush();
       tap(queryAndAssert(element, '.deleteBtn'));
-      assert.isTrue(
-        (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
-      );
+      await element.updateComplete;
+      assert.isTrue(queryAndAssert<GrButton>(element, '.deleteBtn').disabled);
       await promise;
     });
 
-    test('autogenerated prefix hiding', () => {
+    test('autogenerated prefix hiding', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'autogenerated:gerrit:test' as ReviewInputTag,
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      expect(element).shadowDom.to.equal(/* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`);
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      expect(element).shadowDom.to.equal(/* HTML */ '');
     });
 
-    test('reviewer message treated as autogenerated', () => {
+    test('reviewer message treated as autogenerated', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'autogenerated:gerrit:test' as ReviewInputTag,
         reviewer: {},
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      expect(element).shadowDom.to.equal(/* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`);
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      expect(element).shadowDom.to.equal(/* HTML */ '');
     });
 
-    test('batch reviewer message treated as autogenerated', () => {
+    test('batch reviewer message treated as autogenerated', async () => {
       element.message = {
         ...createChangeMessage(),
         type: 'REVIEWER_UPDATE',
         reviewer: {},
         expanded: false,
+        updates: [],
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      expect(element).shadowDom.to.equal(/* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <div class="content"></div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`);
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      expect(element).shadowDom.to.equal(/* HTML */ '');
     });
 
-    test('tag that is not autogenerated prefix does not hide', () => {
+    test('tag that is not autogenerated prefix does not hide', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'something' as ReviewInputTag,
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isFalse(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isFalse(element.computeIsAutomated());
+      const rendered = /* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`;
+      expect(element).shadowDom.to.equal(rendered);
 
       element.hideAutomated = true;
+      await element.updateComplete;
+      console.error(element.computeIsAutomated());
 
-      assert.isFalse(element.hidden);
+      expect(element).shadowDom.to.equal(rendered);
     });
 
     test('reply button hidden unless logged in', () => {
-      const message = {
+      element.message = {
         ...createChangeMessage(),
         message: 'Uploaded patch set 1.',
         expanded: false,
       };
-      assert.isFalse(element._computeShowReplyButton(message, false));
-      assert.isTrue(element._computeShowReplyButton(message, true));
+      element.loggedIn = false;
+      assert.isFalse(element.computeShowReplyButton());
+      element.loggedIn = true;
+      assert.isTrue(element.computeShowReplyButton());
     });
 
     test('_computeShowOnBehalfOf', () => {
@@ -224,29 +331,32 @@
         message: '...',
         expanded: false,
       };
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      element.message = message;
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.author = {_account_id: 1115495 as AccountId};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.real_author = {_account_id: 1115495 as AccountId};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.real_author._account_id = 123456 as AccountId;
-      assert.isOk(element._computeShowOnBehalfOf(message));
+      assert.isOk(element.computeShowOnBehalfOf());
       message.updated_by = message.author;
       delete message.author;
-      assert.isOk(element._computeShowOnBehalfOf(message));
+      assert.isOk(element.computeShowOnBehalfOf());
       delete message.updated_by;
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
     });
 
-    test('clicking on date link fires event', () => {
+    test('clicking on date link fires event', async () => {
       element.message = {
         ...createChangeMessage(),
         type: 'REVIEWER_UPDATE',
         reviewer: {},
         id: '47c43261_55aa2c41' as ChangeMessageId,
         expanded: false,
+        updates: [],
       };
-      flush();
+      await element.updateComplete;
+
       const stub = sinon.stub();
       element.addEventListener('message-anchor-tap', stub);
       const dateEl = queryAndAssert(element, '.date');
@@ -254,7 +364,7 @@
       tap(dateEl);
 
       assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message?.id});
     });
 
     suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
@@ -269,7 +379,7 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 1.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 1 as PatchSetNum,
@@ -283,7 +393,7 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 2.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 2 as PatchSetNum,
@@ -295,7 +405,7 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 200.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 200 as PatchSetNum,
@@ -309,7 +419,7 @@
           ...createChangeMessage(),
           message: 'Commit message updated.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 4 as PatchSetNum,
@@ -323,7 +433,7 @@
           ...createChangeMessage(),
           message: 'abcd↵3 is the latest approved patch-set.↵abc',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 4 as PatchSetNum,
@@ -336,7 +446,7 @@
     suite('compute messages', () => {
       test('empty', () => {
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             true,
             '',
             undefined,
@@ -345,7 +455,7 @@
           ''
         );
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             false,
             '',
             undefined,
@@ -358,13 +468,9 @@
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
-        let actual = element._computeMessageContent(true, original, [], tag);
-        assert.equal(
-          actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
-        );
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, original);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, original);
       });
 
@@ -372,13 +478,9 @@
         const original = 'Patch Set 27: Patch Set 26 was rebased';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        assert.equal(
-          actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
-        );
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -386,31 +488,27 @@
         const original = 'Patch Set 1:\n\nThis change is ready for review.';
         const tag = undefined;
         const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        assert.equal(
-          actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
-        );
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
       test('new patchset with vote', () => {
         const original = 'Uploaded patch set 2: Code-Review+1';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Uploaded patch set 2: Code-Review+1';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
       test('vote', () => {
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -418,9 +516,9 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -434,14 +532,14 @@
           createAccountWithIdNameAndEmail(1),
           createAccountWithIdNameAndEmail(2),
         ];
-        let actual = element._computeMessageContent(
+        let actual = element.computeMessageContent(
           true,
           original,
           accountsInMessage,
           tag
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           accountsInMessage,
@@ -456,9 +554,9 @@
         const tag = undefined;
         const expected =
           'Removed vote: \n\n * Code-Style+1 by Gerrit Account 1\n * Code-Style-1 by Gerrit Account 2';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
     });
@@ -468,11 +566,10 @@
     setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('reply and delete button should be hidden', () => {
+    test('reply and delete button should be hidden', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -487,25 +584,24 @@
         expanded: true,
       };
 
-      flush();
-      assert.isTrue(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
-      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      await element.updateComplete;
+      assert.isNotOk(query<HTMLElement>(element, '.replyActionContainer'));
+      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
     });
   });
 
-  suite('patchset comment summary', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
+  suite('patchset comment summary', async () => {
+    setup(async () => {
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
       element.message = {
         ...createChangeMessage(),
         id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
       };
+      await element.updateComplete;
     });
 
     test('single patchset comment posted', () => {
-      const threads = [
+      element.commentThreads = [
         {
           comments: [
             {
@@ -518,7 +614,6 @@
               message: 'testing the load',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              collapsed: false,
             },
           ],
           patchNum: 1 as PatchSetNum,
@@ -527,23 +622,15 @@
           commentSide: CommentSide.REVISION,
         },
       ];
+      assert.equal(element.patchsetCommentSummary(), 'testing the load');
       assert.equal(
-        element._computeMessageContentCollapsed(
-          '',
-          undefined,
-          undefined,
-          threads
-        ),
-        'testing the load'
-      );
-      assert.equal(
-        element._computeMessageContent(false, '', undefined, undefined),
+        element.computeMessageContent(false, '', undefined, undefined),
         ''
       );
     });
 
     test('single patchset comment with reply', () => {
-      const threads = [
+      element.commentThreads = [
         {
           comments: [
             {
@@ -554,7 +641,6 @@
               message: 'testing the load',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              collapsed: false,
             },
             {
               change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
@@ -566,7 +652,6 @@
               unresolved: false,
               path: '/PATCHSET_LEVEL',
               __draft: true,
-              collapsed: true,
             },
           ],
           patchNum: 1 as PatchSetNum,
@@ -575,17 +660,9 @@
           commentSide: CommentSide.REVISION,
         },
       ];
+      assert.equal(element.patchsetCommentSummary(), 'n');
       assert.equal(
-        element._computeMessageContentCollapsed(
-          '',
-          undefined,
-          undefined,
-          threads
-        ),
-        'n'
-      );
-      assert.equal(
-        element._computeMessageContent(false, '', undefined, undefined),
+        element.computeMessageContent(false, '', undefined, undefined),
         ''
       );
     });
@@ -594,11 +671,10 @@
   suite('when logged in but not admin', () => {
     setup(async () => {
       stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('can see reply but not delete button', () => {
+    test('can see reply but not delete button', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -612,17 +688,16 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
-      flush();
-      assert.isFalse(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
-      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
+      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
     });
 
-    test('reply button shown when message is updated', () => {
+    test('reply button shown when message is updated', async () => {
       element.message = undefined;
-      flush();
+      await element.updateComplete;
+
       let replyEl = query(element, '.replyActionContainer');
       // We don't even expect the button to show up in the DOM when the message
       // is undefined.
@@ -641,10 +716,10 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
-      flush();
+      await element.updateComplete;
+
       replyEl = queryAndAssert(element, '.replyActionContainer');
       assert.isOk(replyEl);
-      assert.isFalse((replyEl as HTMLElement).hidden);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 851cf9a..f6b0ad4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -57,6 +57,7 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {queryAll} from '../../../utils/common-util';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -71,7 +72,7 @@
   all: number;
 }
 
-type CombinedMessage = Omit<
+export type CombinedMessage = Omit<
   FormattedReviewerUpdateInfo | ChangeMessageInfo,
   'tag'
 > & {
@@ -305,7 +306,7 @@
     super.disconnectedCallback();
   }
 
-  scrollToMessage(messageID: string) {
+  async scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
       | GrMessage
@@ -317,13 +318,15 @@
       );
       return;
     }
-    if (!el) {
+    if (!el || !el.message) {
       this._showAllActivity = true;
       setTimeout(() => this.scrollToMessage(messageID));
       return;
     }
 
-    el.set('message.expanded', true);
+    el.message.expanded = true;
+    el.requestUpdate();
+    await el.updateComplete;
     let top = el.offsetTop;
     for (
       let offsetParent = el.offsetParent as HTMLElement | null;
@@ -409,11 +412,14 @@
   }
 
   _updateExpandedStateOfAllMessages(exp: boolean) {
-    if (this._combinedMessages) {
-      for (let i = 0; i < this._combinedMessages.length; i++) {
-        this._combinedMessages[i].expanded = exp;
-        this.notifyPath(`_combinedMessages.${i}.expanded`);
-      }
+    if (!this._combinedMessages) return;
+
+    for (let i = 0; i < this._combinedMessages.length; i++) {
+      this._combinedMessages[i].expanded = exp;
+      this.notifyPath(`_combinedMessages.${i}.expanded`);
+    }
+    for (const message of queryAll<GrMessage>(this, 'gr-message')) {
+      message.requestUpdate('message');
     }
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
deleted file mode 100644
index 0a69d8a..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ /dev/null
@@ -1,528 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-messages-list.js';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
-import {TEST_ONLY} from './gr-messages-list.js';
-import {MessageTag} from '../../../constants/constants.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-createCommentApiMockWithTemplateElement(
-    'gr-messages-list-comment-mock-api', html`
-     <gr-messages-list id="messagesList"></gr-messages-list>
-`);
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-messages-list-comment-mock-api>
-  <gr-messages-list></gr-messages-list>
-</gr-messages-list-comment-mock-api>
-`);
-
-const randomMessage = function(opt_params) {
-  const params = opt_params || {};
-  const author1 = {
-    _account_id: 1115495,
-    name: 'Andrew Bonventre',
-    email: 'andybons@chromium.org',
-  };
-  return {
-    id: params.id || Math.random().toString(),
-    date: params.date || '2016-01-12 20:28:33.038000',
-    message: params.message || Math.random().toString(),
-    _revision_number: params._revision_number || 1,
-    author: params.author || author1,
-    tag: params.tag,
-  };
-};
-
-function generateRandomMessages(count) {
-  return new Array(count).fill()
-      .map(() => randomMessage());
-}
-
-suite('gr-messages-list tests', () => {
-  let element;
-  let messages;
-
-  let commentApiWrapper;
-
-  const getMessages = function() {
-    return element.root.querySelectorAll('gr-message');
-  };
-
-  const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
-  const MESSAGE_ID_1 = '8c19ccc949c6d482b061be6a28e10782abf0e7af';
-  const MESSAGE_ID_2 = 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5';
-
-  const author = {
-    _account_id: 42,
-    name: 'Marvin the Paranoid Android',
-    email: 'marvin@sirius.org',
-  };
-
-  const createComment = function() {
-    return {
-      id: '1a2b3c4d',
-      message: 'some random test text',
-      change_message_id: '8a7b6c5d',
-      updated: '2016-01-01 01:02:03.000000000',
-      line: 1,
-      patch_set: 1,
-      author,
-    };
-  };
-
-  const comments = {
-    file1: [
-      {
-        ...createComment(),
-        change_message_id: MESSAGE_ID_0,
-        in_reply_to: '6505d749_f0bec0aa',
-        author: {
-          email: 'some@email.com',
-          _account_id: 123,
-        },
-      },
-      {
-        ...createComment(),
-        id: '2b3c4d5e',
-        change_message_id: MESSAGE_ID_1,
-        in_reply_to: 'c5912363_6b820105',
-      },
-      {
-        ...createComment(),
-        id: '2b3c4d5e',
-        change_message_id: MESSAGE_ID_1,
-        in_reply_to: '6505d749_f0bec0aa',
-      },
-      {
-        ...createComment(),
-        id: '34ed05d749_10ed44b2',
-        change_message_id: MESSAGE_ID_2,
-      },
-    ],
-    file2: [
-      {
-        ...createComment(),
-        change_message_id: MESSAGE_ID_1,
-        in_reply_to: 'c5912363_4b7d450a',
-        id: '450a935e_4f260d25',
-      },
-    ],
-  };
-
-  suite('basic tests', () => {
-    setup(async () => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      stubRestApi('getDiffComments').returns(Promise.resolve(comments));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-
-      messages = generateRandomMessages(3);
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.messagesList;
-      await element.getCommentsModel().reloadComments();
-      element.messages = messages;
-      await flush();
-    });
-
-    test('expand/collapse all', () => {
-      let allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message._expanded = false;
-      }
-      MockInteractions.tap(allMessageEls[1]);
-      assert.isTrue(allMessageEls[1]._expanded);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isTrue(message._expanded);
-      }
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded);
-      }
-    });
-
-    test('expand/collapse from external keypress', () => {
-      // Start with one expanded message. -> not all collapsed
-      element.scrollToMessage(messages[1].id);
-      assert.isFalse([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'x' -> all expanded
-      element.handleExpandCollapse(true);
-      assert.isTrue([...getMessages()].filter(m => !m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-    });
-
-    test('showAllActivity does not appear when all msgs are important', () => {
-      assert.isOk(element.shadowRoot
-          .querySelector('#showAllActivityToggleContainer'));
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.showAllActivityToggle'));
-    });
-
-    test('scroll to message', () => {
-      const allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message.set('message.expanded', false);
-      }
-
-      const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
-
-      element.scrollToMessage('invalid');
-
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded,
-            'expected gr-message to not be expanded');
-      }
-
-      const messageID = messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-    });
-
-    test('scroll to message offscreen', () => {
-      const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
-      element.messages = generateRandomMessages(25);
-      flush();
-      assert.isFalse(scrollToStub.called);
-      assert.isFalse(highlightStub.called);
-
-      const messageID = element.messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-    });
-
-    test('associating messages with comments', () => {
-      const messages = [].concat(
-          randomMessage(),
-          {
-            _index: 5,
-            _revision_number: 4,
-            message: 'Uploaded patch set 4.',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-          },
-          {
-            _index: 6,
-            _revision_number: 4,
-            message: 'Patch Set 4:\n\n(6 comments)',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
-          }
-      );
-      element.messages = messages;
-      flush();
-      const messageElements = getMessages();
-      assert.equal(messageElements.length, messages.length);
-      assert.deepEqual(messageElements[1].message, messages[1]);
-      assert.deepEqual(messageElements[2].message, messages[2]);
-    });
-
-    test('threads', () => {
-      const messages = [
-        {
-          _index: 5,
-          _revision_number: 4,
-          message: 'Uploaded patch set 4.',
-          date: '2016-09-28 13:36:33.000000000',
-          author,
-          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-        },
-      ];
-      element.messages = messages;
-      flush();
-      const messageElements = getMessages();
-      // threads
-      assert.equal(
-          messageElements[0].message.commentThreads.length,
-          3);
-      // first thread contains 1 comment
-      assert.equal(
-          messageElements[0].message.commentThreads[0].comments.length,
-          1);
-    });
-
-    test('updateTag human message', () => {
-      const m = randomMessage();
-      assert.equal(TEST_ONLY.computeTag(m), undefined);
-    });
-
-    test('updateTag nothing to change', () => {
-      const m = randomMessage();
-      const tag = 'something-normal';
-      m.tag = tag;
-      assert.equal(TEST_ONLY.computeTag(m), tag);
-    });
-
-    test('updateTag TAG_NEW_WIP_PATCHSET', () => {
-      const m = randomMessage();
-      m.tag = MessageTag.TAG_NEW_WIP_PATCHSET;
-      assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
-    });
-
-    test('updateTag remove postfix', () => {
-      const m = randomMessage();
-      m.tag = 'something~withpostfix';
-      assert.equal(TEST_ONLY.computeTag(m), 'something');
-    });
-
-    test('updateTag with robot comments', () => {
-      const m = randomMessage();
-      m.commentThreads = [{
-        comments: [{
-          robot_id: 'id314',
-          change_message_id: m.id,
-        }],
-      }];
-      assert.notEqual(TEST_ONLY.computeTag(m), undefined);
-    });
-
-    test('setRevisionNumber nothing to change', () => {
-      const m1 = randomMessage();
-      const m2 = randomMessage();
-      assert.equal(TEST_ONLY.computeRevision(m1, [m1, m2]), 1);
-      assert.equal(TEST_ONLY.computeRevision(m2, [m1, m2]), 1);
-    });
-
-    test('setRevisionNumber reviewer updates', () => {
-      const m1 = randomMessage(
-          {
-            tag: MessageTag.TAG_REVIEWER_UPDATE,
-            date: '2020-01-01 10:00:00.000000000',
-          });
-      m1._revision_number = undefined;
-      const m2 = randomMessage(
-          {
-            date: '2020-01-02 10:00:00.000000000',
-          });
-      m2._revision_number = 1;
-      const m3 = randomMessage(
-          {
-            tag: MessageTag.TAG_REVIEWER_UPDATE,
-            date: '2020-01-03 10:00:00.000000000',
-          });
-      m3._revision_number = undefined;
-      const m4 = randomMessage(
-          {
-            date: '2020-01-04 10:00:00.000000000',
-          });
-      m4._revision_number = 2;
-      const m5 = randomMessage(
-          {
-            tag: MessageTag.TAG_REVIEWER_UPDATE,
-            date: '2020-01-05 10:00:00.000000000',
-          });
-      m5._revision_number = undefined;
-      const allMessages = [m1, m2, m3, m4, m5];
-      assert.equal(TEST_ONLY.computeRevision(m1, allMessages), undefined);
-      assert.equal(TEST_ONLY.computeRevision(m2, allMessages), 1);
-      assert.equal(TEST_ONLY.computeRevision(m3, allMessages), 1);
-      assert.equal(TEST_ONLY.computeRevision(m4, allMessages), 2);
-      assert.equal(TEST_ONLY.computeRevision(m5, allMessages), 2);
-    });
-
-    test('isImportant human message', () => {
-      const m = randomMessage();
-      assert.isTrue(TEST_ONLY.computeIsImportant(m, []));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m, [m]));
-    });
-
-    test('isImportant even with a tag', () => {
-      const m1 = randomMessage();
-      const m2 = randomMessage({tag: 'autogenerated:gerrit1'});
-      const m3 = randomMessage({tag: 'autogenerated:gerrit2'});
-      assert.isTrue(TEST_ONLY.computeIsImportant(m2, []));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
-    });
-
-    test('isImportant filters same tag and older revision', () => {
-      const m1 = randomMessage({tag: 'auto', _revision_number: 2});
-      const m2 = randomMessage({tag: 'auto', _revision_number: 1});
-      const m3 = randomMessage({tag: 'auto'});
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m2]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m3]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m3]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
-    });
-
-    test('isImportant is evaluated after tag update', () => {
-      const m1 = randomMessage(
-          {tag: MessageTag.TAG_NEW_PATCHSET, _revision_number: 1});
-      const m2 = randomMessage(
-          {tag: MessageTag.TAG_NEW_WIP_PATCHSET, _revision_number: 2});
-      element.messages = [m1, m2];
-      flush();
-      assert.isFalse(m1.isImportant);
-      assert.isTrue(m2.isImportant);
-    });
-
-    test('messages without author do not throw', () => {
-      const messages = [{
-        _index: 5,
-        _revision_number: 4,
-        message: 'Uploaded patch set 4.',
-        date: '2016-09-28 13:36:33.000000000',
-        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-      }];
-      element.messages = messages;
-      flush();
-      const messageEls = getMessages();
-      assert.equal(messageEls.length, 1);
-      assert.equal(messageEls[0].message.message, messages[0].message);
-    });
-  });
-
-  suite('gr-messages-list automate tests', () => {
-    let element;
-    let messages;
-
-    let commentApiWrapper;
-
-    setup(() => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-
-      messages = [
-        randomMessage(),
-        randomMessage({tag: 'auto', _revision_number: 2}),
-        randomMessage({tag: 'auto', _revision_number: 3}),
-      ];
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.messagesList;
-      element.messages = messages;
-      flush();
-    });
-
-    test('hide autogenerated button is not hidden', () => {
-      const toggle = element.root.querySelector('.showAllActivityToggle');
-      assert.isOk(toggle);
-    });
-
-    test('one unimportant message is hidden initially', () => {
-      const displayedMsgs = element.root.querySelectorAll('gr-message');
-      assert.equal(displayedMsgs.length, 2);
-    });
-
-    test('unimportant messages hidden after toggle', () => {
-      element._showAllActivity = true;
-      const toggle = element.root.querySelector('.showAllActivityToggle');
-      assert.isOk(toggle);
-      MockInteractions.tap(toggle);
-      flush();
-      const displayedMsgs = element.root.querySelectorAll('gr-message');
-      assert.equal(displayedMsgs.length, 2);
-    });
-
-    test('unimportant messages shown after toggle', () => {
-      element._showAllActivity = false;
-      const toggle = element.root.querySelector('.showAllActivityToggle');
-      assert.isOk(toggle);
-      MockInteractions.tap(toggle);
-      flush();
-      const displayedMsgs = element.root.querySelectorAll('gr-message');
-      assert.equal(displayedMsgs.length, 3);
-    });
-
-    test('_computeLabelExtremes', () => {
-      const computeSpy = sinon.spy(element, '_computeLabelExtremes');
-
-      element.labels = null;
-      assert.isTrue(computeSpy.calledOnce);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {};
-      assert.isTrue(computeSpy.calledTwice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {}};
-      assert.isTrue(computeSpy.calledThrice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {}}};
-      assert.equal(computeSpy.callCount, 4);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {'-12': {}}}};
-      assert.equal(computeSpy.callCount, 5);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -12, max: -12}});
-
-      element.labels = {
-        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
-      };
-      assert.equal(computeSpy.callCount, 6);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -2, max: 2}});
-
-      element.labels = {
-        'my-label': {values: {'-12': {}}},
-        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
-      };
-      assert.equal(computeSpy.callCount, 7);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
-        'my-label': {min: -12, max: -12},
-        'other-label': {min: -1, max: 1},
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
new file mode 100644
index 0000000..ac487f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -0,0 +1,593 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-messages-list';
+import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
+import {MessageTag} from '../../../constants/constants';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrMessage} from '../gr-message/gr-message';
+import {
+  AccountId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  EmailAddress,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  PatchSetNum,
+  ReviewInputTag,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {assertIsDefined} from '../../../utils/common-util';
+
+const basicFixture = fixtureFromElement('gr-messages-list');
+
+const author = {
+  _account_id: 42 as AccountId,
+  name: 'Marvin the Paranoid Android',
+  email: 'marvin@sirius.org' as EmailAddress,
+};
+
+const createComment = function () {
+  return {
+    id: '1a2b3c4d' as UrlEncodedCommentId,
+    message: 'some random test text',
+    change_message_id: '8a7b6c5d',
+    updated: '2016-01-01 01:02:03.000000000' as Timestamp,
+    line: 1,
+    patch_set: 1 as PatchSetNum,
+    author,
+  };
+};
+
+const randomMessage = function (opt_params?: ChangeMessageInfo) {
+  const params = opt_params || ({} as ChangeMessageInfo);
+  const author1 = {
+    _account_id: 1115495 as AccountId,
+    name: 'Andrew Bonventre',
+    email: 'andybons@chromium.org' as EmailAddress,
+  };
+  return {
+    id: (params.id || Math.random().toString()) as ChangeMessageId,
+    date: (params.date || '2016-01-12 20:28:33.038000') as Timestamp,
+    message: params.message || Math.random().toString(),
+    _revision_number: (params._revision_number || 1) as PatchSetNum,
+    author: params.author || author1,
+    tag: params.tag,
+  };
+};
+
+function generateRandomMessages(count: number) {
+  return new Array(count)
+    .fill(undefined)
+    .map(() => randomMessage()) as ChangeMessageInfo[];
+}
+
+suite('gr-messages-list tests', () => {
+  let element: GrMessagesList;
+  let messages: ChangeMessageInfo[];
+
+  const getMessages = function () {
+    return queryAll<GrMessage>(element, 'gr-message');
+  };
+
+  const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_1 = '8c19ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_2 = 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5';
+
+  const comments = {
+    file1: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_0,
+        in_reply_to: '6505d749_f0bec0aa' as UrlEncodedCommentId,
+        author: {
+          email: 'some@email.com' as EmailAddress,
+          _account_id: 123 as AccountId,
+        },
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e' as UrlEncodedCommentId,
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_6b820105' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e' as UrlEncodedCommentId,
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: '6505d749_f0bec0aa' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        id: '34ed05d749_10ed44b2' as UrlEncodedCommentId,
+        change_message_id: MESSAGE_ID_2,
+      },
+    ],
+    file2: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_4b7d450a' as UrlEncodedCommentId,
+        id: '450a935e_4f260d25' as UrlEncodedCommentId,
+      },
+    ],
+  };
+
+  suite('basic tests', () => {
+    setup(async () => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getDiffComments').returns(Promise.resolve(comments));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+
+      messages = generateRandomMessages(3);
+      element = basicFixture.instantiate();
+      await element.getCommentsModel().reloadComments(0 as NumericChangeId);
+      element.messages = messages;
+      await flush();
+    });
+
+    test('expand/collapse all', async () => {
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assertIsDefined(message.message);
+        message.message = {...message.message, expanded: false};
+        await message.updateComplete;
+      }
+      MockInteractions.tap(allMessageEls[1]);
+      assert.isTrue(allMessageEls[1].message?.expanded);
+
+      MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message.message?.expanded);
+      }
+
+      MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isFalse(message.message?.expanded);
+      }
+    });
+
+    test('expand/collapse from external keypress', () => {
+      // Start with one expanded message. -> not all collapsed
+      element.scrollToMessage(messages[1].id);
+      assert.isFalse(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
+
+      // Press 'x' -> all expanded
+      element.handleExpandCollapse(true);
+      assert.isTrue(
+        [...getMessages()].filter(m => !m.message?.expanded).length === 0
+      );
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
+    });
+
+    test('showAllActivity does not appear when all msgs are important', () => {
+      assert.isOk(query(element, '#showAllActivityToggleContainer'));
+      assert.isNotOk(query(element, '.showAllActivityToggle'));
+    });
+
+    test('scroll to message', async () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assertIsDefined(message.message);
+        message.message = {...message.message, expanded: false};
+      }
+
+      const scrollToStub = sinon.stub(window, 'scrollTo');
+      const highlightStub = sinon.stub(element, '_highlightEl');
+
+      await element.scrollToMessage('invalid');
+
+      for (const message of allMessageEls) {
+        assertIsDefined(message.message);
+        assert.isFalse(
+          message.message.expanded,
+          'expected gr-message to not be expanded'
+        );
+      }
+
+      const messageID = messages[1].id;
+      await element.scrollToMessage(messageID);
+      assert.isTrue(
+        queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
+          .message?.expanded
+      );
+
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+    });
+
+    test('scroll to message offscreen', async () => {
+      const scrollToStub = sinon.stub(window, 'scrollTo');
+      const highlightStub = sinon.stub(element, '_highlightEl');
+      element.messages = generateRandomMessages(25);
+      await element.updateComplete;
+      assert.isFalse(scrollToStub.called);
+      assert.isFalse(highlightStub.called);
+
+      const messageID = element.messages[1].id;
+      await element.scrollToMessage(messageID);
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+      assert.isTrue(
+        queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
+          .message?.expanded
+      );
+    });
+
+    test('associating messages with comments', () => {
+      // Have to type as any otherwise fails with
+      // Argument of type 'ChangeMessageInfo[]' is not assignable to
+      // parameter of type 'ConcatArray<never>'.
+      const messages = ([] as any).concat(
+        randomMessage(),
+        {
+          _index: 5,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          author,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af' as ChangeMessageId,
+        } as CombinedMessage,
+        {
+          _index: 6,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Patch Set 4:\n\n(6 comments)',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          author,
+          id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5' as ChangeMessageId,
+        } as CombinedMessage
+      );
+      element.messages = messages;
+      flush();
+      const messageElements = getMessages();
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+    });
+
+    test('threads', () => {
+      const messages = [
+        {
+          _index: 5,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          author,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af' as ChangeMessageId,
+        },
+      ];
+      element.messages = messages;
+      flush();
+      const messageElements = getMessages();
+      // threads
+      assert.equal(messageElements[0].message!.commentThreads.length, 3);
+      // first thread contains 1 comment
+      assert.equal(
+        messageElements[0].message!.commentThreads[0].comments.length,
+        1
+      );
+    });
+
+    test('updateTag human message', () => {
+      const m = randomMessage();
+      assert.equal(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('updateTag nothing to change', () => {
+      const m = randomMessage();
+      const tag = 'something-normal' as ReviewInputTag;
+      m.tag = tag;
+      assert.equal(TEST_ONLY.computeTag(m), tag);
+    });
+
+    test('updateTag TAG_NEW_WIP_PATCHSET', () => {
+      const m = randomMessage();
+      m.tag = MessageTag.TAG_NEW_WIP_PATCHSET as ReviewInputTag;
+      assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
+    });
+
+    test('updateTag remove postfix', () => {
+      const m = randomMessage();
+      m.tag = 'something~withpostfix' as ReviewInputTag;
+      assert.equal(TEST_ONLY.computeTag(m), 'something');
+    });
+
+    test('updateTag with robot comments', () => {
+      const m = randomMessage();
+      (m as any).commentThreads = [
+        {
+          comments: [
+            {
+              robot_id: 'id314',
+              change_message_id: m.id,
+            },
+          ],
+        },
+      ];
+      assert.notEqual(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('setRevisionNumber nothing to change', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage();
+      assert.equal(TEST_ONLY.computeRevision(m1, [m1, m2]), 1 as PatchSetNum);
+      assert.equal(TEST_ONLY.computeRevision(m2, [m1, m2]), 1 as PatchSetNum);
+    });
+
+    test('setRevisionNumber reviewer updates', () => {
+      const m1 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+        date: '2020-01-01 10:00:00.000000000' as Timestamp,
+      });
+      m1._revision_number = 0 as PatchSetNum;
+      const m2 = randomMessage({
+        ...randomMessage(),
+        date: '2020-01-02 10:00:00.000000000' as Timestamp,
+      });
+      m2._revision_number = 1 as PatchSetNum;
+      const m3 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+        date: '2020-01-03 10:00:00.000000000' as Timestamp,
+      });
+      m3._revision_number = 0 as PatchSetNum;
+      const m4 = randomMessage({
+        ...randomMessage(),
+        date: '2020-01-04 10:00:00.000000000' as Timestamp,
+      });
+      m4._revision_number = 2 as PatchSetNum;
+      const m5 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+        date: '2020-01-05 10:00:00.000000000' as Timestamp,
+      });
+      m5._revision_number = 0 as PatchSetNum;
+      const allMessages = [m1, m2, m3, m4, m5];
+      assert.equal(TEST_ONLY.computeRevision(m1, allMessages), undefined);
+      assert.equal(
+        TEST_ONLY.computeRevision(m2, allMessages),
+        1 as PatchSetNum
+      );
+      assert.equal(
+        TEST_ONLY.computeRevision(m3, allMessages),
+        1 as PatchSetNum
+      );
+      assert.equal(
+        TEST_ONLY.computeRevision(m4, allMessages),
+        2 as PatchSetNum
+      );
+      assert.equal(
+        TEST_ONLY.computeRevision(m5, allMessages),
+        2 as PatchSetNum
+      );
+    });
+
+    test('isImportant human message', () => {
+      const m = randomMessage();
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, [m]));
+    });
+
+    test('isImportant even with a tag', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage({
+        ...randomMessage(),
+        tag: 'autogenerated:gerrit1' as ReviewInputTag,
+      });
+      const m3 = randomMessage({
+        ...randomMessage(),
+        tag: 'autogenerated:gerrit2' as ReviewInputTag,
+      });
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant filters same tag and older revision', () => {
+      const m1 = randomMessage({
+        ...randomMessage(),
+        tag: 'auto' as ReviewInputTag,
+        _revision_number: 2 as PatchSetNum,
+      });
+      const m2 = randomMessage({
+        ...randomMessage(),
+        tag: 'auto' as ReviewInputTag,
+        _revision_number: 1 as PatchSetNum,
+      });
+      const m3 = randomMessage({
+        ...randomMessage(),
+        tag: 'auto' as ReviewInputTag,
+      });
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant is evaluated after tag update', () => {
+      const m1 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_NEW_PATCHSET as ReviewInputTag,
+        _revision_number: 1 as PatchSetNum,
+      });
+      const m2 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_NEW_WIP_PATCHSET as ReviewInputTag,
+        _revision_number: 2 as PatchSetNum,
+      });
+      element.messages = [m1, m2];
+      flush();
+      assert.isFalse((m1 as CombinedMessage).isImportant);
+      assert.isTrue((m2 as CombinedMessage).isImportant);
+    });
+
+    test('messages without author do not throw', () => {
+      const messages = [
+        {
+          _index: 5,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af' as ChangeMessageId,
+        },
+      ];
+      element.messages = messages;
+      flush();
+      const messageEls = getMessages();
+      assert.equal(messageEls.length, 1);
+      assert.equal(messageEls[0].message!.message, messages[0].message);
+    });
+  });
+
+  suite('gr-messages-list automate tests', () => {
+    let element: GrMessagesList;
+    let messages: ChangeMessageInfo[];
+
+    setup(() => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+
+      messages = [
+        randomMessage(),
+        randomMessage({
+          ...randomMessage(),
+          tag: 'auto' as ReviewInputTag,
+          _revision_number: 2 as PatchSetNum,
+        }),
+        randomMessage({
+          ...randomMessage(),
+          tag: 'auto' as ReviewInputTag,
+          _revision_number: 3 as PatchSetNum,
+        }),
+      ];
+
+      element = basicFixture.instantiate();
+      element.messages = messages;
+      flush();
+    });
+
+    test('hide autogenerated button is not hidden', () => {
+      const toggle = queryAndAssert(element, '.showAllActivityToggle');
+      assert.isOk(toggle);
+    });
+
+    test('one unimportant message is hidden initially', () => {
+      const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
+      assert.equal(displayedMsgs.length, 2);
+    });
+
+    test('unimportant messages hidden after toggle', () => {
+      element._showAllActivity = true;
+      const toggle = queryAndAssert(element, '.showAllActivityToggle');
+      assert.isOk(toggle);
+      MockInteractions.tap(toggle);
+      flush();
+      const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
+      assert.equal(displayedMsgs.length, 2);
+    });
+
+    test('unimportant messages shown after toggle', () => {
+      element._showAllActivity = false;
+      const toggle = queryAndAssert(element, '.showAllActivityToggle');
+      assert.isOk(toggle);
+      MockInteractions.tap(toggle);
+      flush();
+      const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
+      assert.equal(displayedMsgs.length, 3);
+    });
+
+    test('_computeLabelExtremes', () => {
+      const computeSpy = sinon.spy(element, '_computeLabelExtremes');
+
+      // Have to type as any to be able to use null.
+      element.labels = null as any;
+      assert.isTrue(computeSpy.calledOnce);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {};
+      assert.isTrue(computeSpy.calledTwice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {}};
+      assert.isTrue(computeSpy.calledThrice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {values: {}}};
+      assert.equal(computeSpy.callCount, 4);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+      } as LabelNameToInfoMap;
+      assert.equal(computeSpy.callCount, 5);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {
+        'my-label': {min: -12, max: -12},
+      });
+
+      element.labels = {
+        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
+      } as LabelNameToInfoMap;
+      assert.equal(computeSpy.callCount, 6);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {
+        'my-label': {min: -2, max: 2},
+      });
+
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
+      } as LabelNameToInfoMap;
+      assert.equal(computeSpy.callCount, 7);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {
+        'my-label': {min: -12, max: -12},
+        'other-label': {min: -1, max: 1},
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 744db3b..cbb29de 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -114,9 +114,9 @@
     return html`
       <div class="changeContainer">
         <a
-          href="${ifDefined(this.href)}"
-          aria-label="${ifDefined(this.label)}"
-          class="${linkClass}"
+          href=${ifDefined(this.href)}
+          aria-label=${ifDefined(this.label)}
+          class=${linkClass}
           ><slot></slot
         ></a>
         ${this.showSubmittableCheck
@@ -130,7 +130,7 @@
             >`
           : ''}
         ${this.showChangeStatus && !isChangeInfo(change)
-          ? html`<span class="${this._computeChangeStatusClass(change)}">
+          ? html`<span class=${this._computeChangeStatusClass(change)}>
               (${this._computeChangeStatus(change)})
             </span>`
           : ''}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index c882764..05fe62c 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -202,31 +202,31 @@
     return html`<section id="relatedChanges">
       <gr-related-collapse
         title="Relation chain"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
       >
         ${this.relatedChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   relatedChangesMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 relatedChangesMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .connectedRevisions="${connectedRevisions}"
-                .href="${change?._change_number
+                .change=${change}
+                .connectedRevisions=${connectedRevisions}
+                .href=${change?._change_number
                   ? GerritNav.getUrlForChangeById(
                       change._change_number,
                       change.project,
                       change._revision_number as PatchSetNum
                     )
-                  : ''}"
+                  : ''}
                 .showChangeStatus=${true}
                 >${change.commit.subject}</gr-related-change
               >
@@ -259,28 +259,28 @@
     return html`<section id="submittedTogether">
       <gr-related-collapse
         title="Submitted together"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${submittedTogetherChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
       >
         ${submittedTogetherChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   submittedTogetherMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
               )}<gr-related-change
-                .label="${this.renderChangeTitle(change)}"
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
+                .label=${this.renderChangeTitle(change)}
+                .change=${change}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 .showSubmittableCheck=${true}
                 >${this.renderChangeLine(change)}</gr-related-change
               >
@@ -309,28 +309,28 @@
     return html`<section id="sameTopic">
       <gr-related-collapse
         title="Same topic"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.sameTopicChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
       >
         ${this.sameTopicChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   sameTopicMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .label="${this.renderChangeTitle(change)}"
-                .href="${GerritNav.getUrlForChangeById(
+                .change=${change}
+                .label=${this.renderChangeTitle(change)}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
@@ -354,27 +354,27 @@
     return html`<section id="mergeConflicts">
       <gr-related-collapse
         title="Merge conflicts"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.conflictingChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
       >
         ${this.conflictingChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   mergeConflictsMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 mergeConflictsMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
+                .change=${change}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 >${change.subject}</gr-related-change
               >
             </div>`
@@ -398,27 +398,27 @@
     return html`<section id="cherryPicks">
       <gr-related-collapse
         title="Cherry picks"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.cherryPickChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
       >
         ${this.cherryPickChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   cherryPicksMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 cherryPicksMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
+                .change=${change}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 >${change.branch}: ${change.subject}</gr-related-change
               >
             </div>`
@@ -433,7 +433,7 @@
 
   private renderChangeLine(change: ChangeInfo) {
     const truncatedRepo = truncatePath(change.project, 2);
-    return html`<span class="truncatedRepo" .title="${change.project}"
+    return html`<span class="truncatedRepo" .title=${change.project}
         >${truncatedRepo}</span
       >: ${change.branch}: ${change.subject}`;
   }
@@ -775,7 +775,7 @@
         buttonText = `Show all (${this.length})`;
         buttonIcon = 'expand-more';
       }
-      button = html`<gr-button link="" @click="${this.toggle}"
+      button = html`<gr-button link="" @click=${this.toggle}
         >${buttonText}<iron-icon icon="gr-icons:${buttonIcon}"></iron-icon
       ></gr-button>`;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 94b8668..f8c8317 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -214,7 +214,7 @@
         section,
         'gr-related-collapse'
       );
-      assert.isTrue(relatedChanges!.classList.contains('first'));
+      assert.isTrue(relatedChanges.classList.contains('first'));
     });
 
     test('first empty second non-empty', async () => {
@@ -231,7 +231,7 @@
         queryAndAssert<HTMLElement>(element, '#submittedTogether'),
         'gr-related-collapse'
       );
-      assert.isTrue(submittedTogetherSection!.classList.contains('first'));
+      assert.isTrue(submittedTogetherSection.classList.contains('first'));
     });
 
     test('first non-empty second empty third non-empty', async () => {
@@ -249,7 +249,7 @@
         queryAndAssert<HTMLElement>(element, '#relatedChanges'),
         'gr-related-collapse'
       );
-      assert.isTrue(relatedChanges!.classList.contains('first'));
+      assert.isTrue(relatedChanges.classList.contains('first'));
       const submittedTogetherSection = query<HTMLElement>(
         element,
         '#submittedTogether'
@@ -259,7 +259,7 @@
         queryAndAssert<HTMLElement>(element, '#cherryPicks'),
         'gr-related-collapse'
       );
-      assert.isFalse(cherryPicks!.classList.contains('first'));
+      assert.isFalse(cherryPicks.classList.contains('first'));
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
deleted file mode 100644
index 8e78d4e..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-reply-dialog.js';
-
-import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-const basicFixture = fixtureFromElement('gr-reply-dialog');
-
-suite('gr-reply-dialog-it tests', () => {
-  let element;
-  let changeNum;
-  let patchNum;
-
-  const setupElement = element => {
-    element.change = {
-      _number: changeNum,
-      labels: {
-        'Verified': {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified',
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          all: [{_account_id: 42, value: 0}],
-          default_value: 0,
-        },
-      },
-    };
-    element.patchNum = patchNum;
-    element.permittedLabels = {
-      'Code-Review': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-  };
-
-  setup(() => {
-    changeNum = 42;
-    patchNum = 1;
-
-    stubRestApi('getAccount').returns(Promise.resolve({_account_id: 42}));
-
-    element = basicFixture.instantiate();
-    setupElement(element);
-    // Allow the elements created by dom-repeat to be stamped.
-    flush();
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('_submit blocked when invalid email is supplied to ccs', () => {
-    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
-
-    element.$.ccs.$.entry.setText('test');
-    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
-    assert.isFalse(sendStub.called);
-    flush();
-
-    element.$.ccs.$.entry.setText('test@test.test');
-    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
-    assert.isTrue(sendStub.called);
-  });
-
-  test('lgtm plugin', async () => {
-    resetPlugins();
-    window.Gerrit.install(plugin => {
-      const replyApi = plugin.changeReply();
-      replyApi.addReplyTextChangedCallback(text => {
-        const label = 'Code-Review';
-        const labelValue = replyApi.getLabelValue(label);
-        if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
-          replyApi.setLabelValue(label, '+1');
-        }
-      });
-    }, null, 'http://test.com/plugins/lgtm.js');
-    element = basicFixture.instantiate();
-    setupElement(element);
-    getPluginLoader().loadPlugins([]);
-    await getPluginLoader().awaitPluginsLoaded();
-    await flush();
-    const textarea = queryAndAssert(element, 'gr-textarea').getNativeTextarea();
-    textarea.value = 'LGTM';
-    textarea.dispatchEvent(
-        new CustomEvent('input', {bubbles: true, composed: true}));
-    await flush();
-    const labelScoreRows = element.getLabelScores().shadowRoot.querySelector(
-        'gr-label-score-row[name="Code-Review"]');
-    const selectedBtn =
-        labelScoreRows.shadowRoot.querySelector('gr-button[data-value="+1"]');
-    assert.isOk(selectedBtn);
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
new file mode 100644
index 0000000..5bd5d08
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-reply-dialog';
+import {
+  queryAndAssert,
+  resetPlugins,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {GrReplyDialog} from './gr-reply-dialog';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {
+  AccountId,
+  NumericChangeId,
+  PatchSetNum,
+  Timestamp,
+} from '../../../types/common';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {createChange} from '../../../test/test-data-generators';
+import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+
+suite('gr-reply-dialog-it tests', () => {
+  let element: GrReplyDialog;
+  let changeNum: NumericChangeId;
+  let patchNum: PatchSetNum;
+
+  const setupElement = (element: GrReplyDialog) => {
+    element.change = {
+      ...createChange(),
+      _number: changeNum,
+      labels: {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'This shall not be submitted',
+            '-1': 'I would prefer this is not submitted as is',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          all: [{_account_id: 42 as AccountId, value: 0}],
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': ['-1', ' 0', '+1'],
+      Verified: ['-1', ' 0', '+1'],
+    };
+  };
+
+  setup(async () => {
+    changeNum = 42 as NumericChangeId;
+    patchNum = 1 as PatchSetNum;
+
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _account_id: 42 as AccountId,
+        registered_on: '' as Timestamp,
+      })
+    );
+
+    element = await fixture<GrReplyDialog>(html`
+      <gr-reply-dialog></gr-reply-dialog>
+    `);
+    setupElement(element);
+
+    await element.updateComplete;
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('submit blocked when invalid email is supplied to ccs', () => {
+    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
+
+    element.ccsList!.entry!.setText('test');
+    MockInteractions.tap(queryAndAssert(element, 'gr-button.send'));
+    assert.isFalse(element.ccsList!.submitEntryText());
+    assert.isFalse(sendStub.called);
+    flush();
+
+    element.ccsList!.entry!.setText('test@test.test');
+    MockInteractions.tap(queryAndAssert(element, 'gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('lgtm plugin', async () => {
+    resetPlugins();
+    window.Gerrit.install(
+      plugin => {
+        const replyApi = plugin.changeReply();
+        replyApi.addReplyTextChangedCallback(text => {
+          const label = 'Code-Review';
+          const labelValue = replyApi.getLabelValue(label);
+          if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
+            replyApi.setLabelValue(label, '+1');
+          }
+        });
+      },
+      undefined,
+      'http://test.com/plugins/lgtm.js'
+    );
+    element = basicFixture.instantiate();
+    setupElement(element);
+    getPluginLoader().loadPlugins([]);
+    await getPluginLoader().awaitPluginsLoaded();
+    await flush();
+    const textarea = queryAndAssert<GrTextarea>(
+      element,
+      'gr-textarea'
+    ).getNativeTextarea();
+    textarea.value = 'LGTM';
+    textarea.dispatchEvent(
+      new CustomEvent('input', {bubbles: true, composed: true})
+    );
+    await flush();
+    const labelScoreRows = queryAndAssert(
+      element.getLabelScores(),
+      'gr-label-score-row[name="Code-Review"]'
+    );
+    const selectedBtn = queryAndAssert(
+      labelScoreRows,
+      'gr-button[data-value="+1"]'
+    );
+    assert.isOk(selectedBtn);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index a0db9fa..6a9bd92 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,11 +27,7 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {htmlTemplate} from './gr-reply-dialog_html';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
@@ -47,14 +43,14 @@
 } from '../../../utils/account-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
   AccountAddition,
   AccountInfoInput,
+  AccountInput,
+  AccountInputDetail,
   GrAccountList,
   GroupInfoInput,
-  GroupObjectInput,
   RawAccountInput,
 } from '../../shared/gr-account-list/gr-account-list';
 import {
@@ -63,32 +59,24 @@
   AttentionSetInput,
   ChangeInfo,
   CommentInput,
-  EmailAddress,
-  GroupId,
   GroupInfo,
   isAccount,
   isDetailedLabelInfo,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
-  LabelNameToValueMap,
   ParsedJSON,
   PatchSetNum,
-  ProjectInfo,
   ReviewerInput,
-  Reviewers,
   ReviewInput,
   ReviewResult,
   ServerInfo,
+  SuggestedReviewerGroupInfo,
   Suggestion,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {
-  PolymerDeepPropertyChange,
-  PolymerSpliceChange,
-} from '@polymer/polymer/interfaces';
-import {
   areSetsEqual,
   assertIsDefined,
   containsAll,
@@ -118,8 +106,16 @@
 import {getReplyByReason} from '../../../utils/attention-set-util';
 import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {ConfigInfo, LabelNameToValuesMap} from '../../../api/rest-api';
+import {css, html, PropertyValues, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {when} from 'lit/directives/when';
+import {classMap} from 'lit/directives/class-map';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {customElement, property, state, query} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -155,24 +151,8 @@
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-export interface GrReplyDialog {
-  $: {
-    reviewers: GrAccountList;
-    ccs: GrAccountList;
-    cancelButton: GrButton;
-    sendButton: GrButton;
-    labelScores: GrLabelScores;
-    textarea: GrTextarea;
-    reviewerConfirmationOverlay: GrOverlay;
-  };
-}
-
 @customElement('gr-reply-dialog')
-export class GrReplyDialog extends DIPolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrReplyDialog extends LitElement {
   /**
    * Fired when a reply is successfully sent.
    *
@@ -232,131 +212,129 @@
   @property({type: Boolean})
   canBeStarted = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({
-    type: Boolean,
-    computed: '_computeHasDrafts(draft, draftCommentThreads.*)',
-  })
-  hasDrafts = false;
-
-  @property({type: String, observer: '_draftChanged'})
-  draft = '';
+  @property({type: Array})
+  draftCommentThreads: CommentThread[] | undefined;
 
   @property({type: Object})
-  filterReviewerSuggestion: (input: Suggestion) => boolean;
+  permittedLabels?: LabelNameToValuesMap;
 
   @property({type: Object})
-  filterCCSuggestion: (input: Suggestion) => boolean;
-
-  @property({type: Object})
-  permittedLabels?: LabelNameToValueMap;
-
-  @property({type: Object})
-  projectConfig?: ProjectInfo;
+  projectConfig?: ConfigInfo;
 
   @property({type: Object})
   serverConfig?: ServerInfo;
 
-  @property({type: String})
+  @query('#reviewers') reviewersList?: GrAccountList;
+
+  @query('#ccs') ccsList?: GrAccountList;
+
+  @query('#cancelButton') cancelButton?: GrButton;
+
+  @query('#sendButton') sendButton?: GrButton;
+
+  @query('#labelScores') labelScores?: GrLabelScores;
+
+  @query('#textarea') textarea?: GrTextarea;
+
+  @query('#reviewerConfirmationOverlay')
+  reviewerConfirmationOverlay?: GrOverlay;
+
+  @state()
+  draft = '';
+
+  @state()
+  filterReviewerSuggestion: (input: Suggestion) => boolean;
+
+  @state()
+  filterCCSuggestion: (input: Suggestion) => boolean;
+
+  @state()
   knownLatestState?: LatestPatchState;
 
-  @property({type: Boolean})
+  @state()
   underReview = true;
 
-  @property({type: Object})
-  _account?: AccountInfo;
+  @state()
+  account?: AccountInfo;
 
-  @property({type: Array})
-  _ccs: (AccountInfo | GroupInfo)[] = [];
+  @state()
+  ccs: AccountInput[] = [];
 
-  @property({type: Number})
-  _attentionCcsCount = 0;
+  @state()
+  attentionCcsCount = 0;
 
-  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _ccPendingConfirmation: GroupObjectInput | null = null;
+  @state()
+  ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
-  @property({
-    type: String,
-    computed: '_computeMessagePlaceholder(canBeStarted)',
-  })
-  _messagePlaceholder?: string;
+  @state()
+  messagePlaceholder?: string;
 
-  @property({type: Object})
-  _owner?: AccountInfo;
+  @state()
+  owner?: AccountInfo;
 
-  @property({type: Object, computed: '_computeUploader(change)'})
-  _uploader?: AccountInfo;
+  @state()
+  uploader?: AccountInfo;
 
-  @property({type: Object})
-  _pendingConfirmationDetails: GroupObjectInput | null = null;
+  @state()
+  pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null;
 
-  @property({type: Boolean})
-  _includeComments = true;
+  @state()
+  includeComments = true;
 
-  @property({type: Array})
-  _reviewers: (AccountInfo | GroupInfo)[] = [];
+  @state() reviewers: AccountInput[] = [];
 
-  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _reviewerPendingConfirmation: GroupObjectInput | null = null;
+  @state()
+  reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
-  @property({type: Boolean, observer: '_handleHeightChanged'})
-  _previewFormatting = false;
+  @state()
+  previewFormatting = false;
 
-  @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
-  _sendButtonLabel?: string;
+  @state()
+  sendButtonLabel?: string;
 
-  @property({type: Boolean})
-  _savingComments = false;
+  @state()
+  savingComments = false;
 
-  @property({type: Boolean})
-  _reviewersMutated = false;
+  @state()
+  reviewersMutated = false;
 
   /**
    * Signifies that the user has changed their vote on a label or (if they have
    * not yet voted on a label) if a selected vote is different from the default
    * vote.
    */
-  @property({type: Boolean})
-  _labelsChanged = false;
+  @state()
+  labelsChanged = false;
 
-  @property({type: String})
-  readonly _saveTooltip: string = ButtonTooltips.SAVE;
+  @state()
+  readonly saveTooltip: string = ButtonTooltips.SAVE;
 
-  @property({type: String})
-  _pluginMessage = '';
+  @state()
+  pluginMessage = '';
 
-  @property({type: Boolean})
-  _commentEditing = false;
+  @state()
+  commentEditing = false;
 
-  @property({type: Boolean})
-  _attentionExpanded = false;
+  @state()
+  attentionExpanded = false;
 
-  @property({type: Object})
-  _currentAttentionSet: Set<AccountId> = new Set();
+  @state()
+  currentAttentionSet: Set<AccountId> = new Set();
 
-  @property({type: Object})
-  _newAttentionSet: Set<AccountId> = new Set();
+  @state()
+  newAttentionSet: Set<AccountId> = new Set();
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeSendButtonDisabled(canBeStarted, ' +
-      'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-      '_includeComments, disabled, _commentEditing, change, _account)',
-    observer: '_sendDisabledChanged',
-  })
-  _sendDisabled?: boolean;
+  @state()
+  sendDisabled?: boolean;
 
-  @property({type: Array, observer: '_handleHeightChanged'})
-  draftCommentThreads: CommentThread[] | undefined;
+  @state()
+  isResolvedPatchsetLevelComment = true;
 
-  @property({type: Boolean})
-  _isResolvedPatchsetLevelComment = true;
-
-  @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
-  _allReviewers: (AccountInfo | GroupInfo)[] = [];
+  @state()
+  allReviewers: (AccountInfo | GroupInfo)[] = [];
 
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
@@ -365,16 +343,267 @@
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  private storeTask?: DelayedTask;
+  storeTask?: DelayedTask;
+
+  private isLoggedIn = false;
 
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        background-color: var(--dialog-background-color);
+        display: block;
+        max-height: 90vh;
+        --label-score-padding-left: var(--spacing-xl);
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: 0.5;
+      }
+      section {
+        border-top: 1px solid var(--border-color);
+        flex-shrink: 0;
+        padding: var(--spacing-m) var(--spacing-xl);
+        width: 100%;
+      }
+      section.labelsContainer {
+        /* We want the :hover highlight to extend to the border of the dialog. */
+        padding: var(--spacing-m) 0;
+      }
+      .stickyBottom {
+        background-color: var(--dialog-background-color);
+        box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+        margin-top: var(--spacing-s);
+        bottom: 0;
+        position: sticky;
+        /* @see Issue 8602 */
+        z-index: 1;
+      }
+      .stickyBottom.newReplyDialog {
+        margin-top: unset;
+      }
+      .actions {
+        display: flex;
+        justify-content: space-between;
+      }
+      .actions .right gr-button {
+        margin-left: var(--spacing-l);
+      }
+      .peopleContainer,
+      .labelsContainer {
+        flex-shrink: 0;
+      }
+      .peopleContainer {
+        border-top: none;
+        display: table;
+      }
+      .peopleList {
+        display: flex;
+      }
+      .peopleListLabel {
+        color: var(--deemphasized-text-color);
+        margin-top: var(--spacing-xs);
+        min-width: 6em;
+        padding-right: var(--spacing-m);
+      }
+      gr-account-list {
+        display: flex;
+        flex-wrap: wrap;
+        flex: 1;
+      }
+      #reviewerConfirmationOverlay {
+        padding: var(--spacing-l);
+        text-align: center;
+      }
+      .reviewerConfirmationButtons {
+        margin-top: var(--spacing-l);
+      }
+      .groupName {
+        font-weight: var(--font-weight-bold);
+      }
+      .groupSize {
+        font-style: italic;
+      }
+      .textareaContainer {
+        min-height: 12em;
+        position: relative;
+      }
+      .newReplyDialog.textareaContainer {
+        min-height: unset;
+      }
+      textareaContainer,
+      #textarea,
+      gr-endpoint-decorator[name='reply-text'] {
+        display: flex;
+        width: 100%;
+      }
+      .newReplyDialog .textareaContainer,
+      #textarea,
+      gr-endpoint-decorator[name='reply-text'] {
+        display: block;
+        width: unset;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: calc(var(--font-size-code) + var(--spacing-s));
+        font-weight: var(--font-weight-normal);
+      }
+      .newReplyDialog#textarea {
+        padding: var(--spacing-m);
+      }
+      gr-endpoint-decorator[name='reply-text'] {
+        flex-direction: column;
+      }
+      #textarea {
+        flex: 1;
+      }
+      .previewContainer {
+        border-top: none;
+      }
+      .previewContainer gr-formatted-text {
+        background: var(--table-header-background-color);
+        padding: var(--spacing-l);
+      }
+      #checkingStatusLabel,
+      #notLatestLabel {
+        margin-left: var(--spacing-l);
+      }
+      #checkingStatusLabel {
+        color: var(--deemphasized-text-color);
+        font-style: italic;
+      }
+      #notLatestLabel,
+      #savingLabel {
+        color: var(--error-text-color);
+      }
+      #savingLabel {
+        display: none;
+      }
+      #savingLabel.saving {
+        display: inline;
+      }
+      #pluginMessage {
+        color: var(--deemphasized-text-color);
+        margin-left: var(--spacing-l);
+        margin-bottom: var(--spacing-m);
+      }
+      #pluginMessage:empty {
+        display: none;
+      }
+      .preview-formatting {
+        margin-left: var(--spacing-m);
+      }
+      .attention-icon {
+        width: 14px;
+        height: 14px;
+        vertical-align: top;
+        position: relative;
+        top: 3px;
+        --iron-icon-height: 24px;
+        --iron-icon-width: 24px;
+      }
+      .attention .edit-attention-button {
+        vertical-align: top;
+        --gr-button-padding: 0px 4px;
+      }
+      .attention .edit-attention-button iron-icon {
+        color: inherit;
+      }
+      .attention a,
+      .attention-detail a {
+        text-decoration: none;
+      }
+      .attentionSummary {
+        display: flex;
+        justify-content: space-between;
+      }
+      .attentionSummary {
+        /* The account label for selection is misbehaving currently: It consumes
+          26px height instead of 20px, which is the default line-height and thus
+          the max that can be nicely fit into an inline layout flow. We
+          acknowledge that using a fixed 26px value here is a hack and not a
+          great solution. */
+        line-height: 26px;
+      }
+      .attentionSummary gr-account-label,
+      .attention-detail gr-account-label {
+        --account-max-length: 120px;
+        display: inline-block;
+        padding: var(--spacing-xs) var(--spacing-m);
+        user-select: none;
+        --label-border-radius: 8px;
+      }
+      .attentionSummary gr-account-label {
+        margin: 0 var(--spacing-xs);
+        line-height: var(--line-height-normal);
+        vertical-align: top;
+      }
+      .attention-detail .peopleListValues {
+        line-height: calc(var(--line-height-normal) + 10px);
+      }
+      .attention-detail gr-account-label {
+        line-height: var(--line-height-normal);
+      }
+      .attentionSummary gr-account-label:focus,
+      .attention-detail gr-account-label:focus {
+        outline: none;
+      }
+      .attentionSummary gr-account-label:hover,
+      .attention-detail gr-account-label:hover {
+        box-shadow: var(--elevation-level-1);
+        cursor: pointer;
+      }
+      .attention-detail .attentionDetailsTitle {
+        display: flex;
+        justify-content: space-between;
+      }
+      .attention-detail .selectUsers {
+        color: var(--deemphasized-text-color);
+        margin-bottom: var(--spacing-m);
+      }
+      .attentionTip {
+        padding: var(--spacing-m);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        margin-top: var(--spacing-m);
+        background-color: var(--assignee-highlight-color);
+      }
+      .attentionTip div iron-icon {
+        margin-right: var(--spacing-s);
+      }
+      .patchsetLevelContainer {
+        width: 80ch;
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-2);
+      }
+      .patchsetLevelContainer.resolved {
+        background-color: var(--comment-background-color);
+      }
+      .patchsetLevelContainer.unresolved {
+        background-color: var(--unresolved-comment-background-color);
+      }
+      .labelContainer {
+        padding-left: var(--spacing-m);
+        padding-bottom: var(--spacing-m);
+      }
+    `,
+  ];
+
   constructor() {
     super();
     this.filterReviewerSuggestion =
-      this._filterReviewerSuggestionGenerator(false);
-    this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
+      this.filterReviewerSuggestionGenerator(false);
+    this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
+    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
   }
 
   override connectedCallback() {
@@ -382,23 +611,23 @@
     (
       IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
     ).requestAvailability();
-    this._getAccount().then(account => {
-      if (account) this._account = account;
+    this.restApiService.getAccount().then(account => {
+      if (account) this.account = account;
     });
 
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this._submit()
+        this.submit()
       )
     );
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this._submit()
+        this.submit()
       )
     );
     this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
     this.addEventListener('comment-editing-changed', e => {
-      this._commentEditing = (e as CustomEvent).detail;
+      this.commentEditing = (e as CustomEvent).detail;
     });
 
     // Plugins on reply-reviewers endpoint can take advantage of these
@@ -407,19 +636,62 @@
     this.addEventListener('add-reviewer', e => {
       // Only support account type, see more from:
       // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
-      this.$.reviewers.addAccountItem({
+      this.reviewersList?.addAccountItem({
         account: (e as CustomEvent).detail.reviewer,
+        count: 1,
       });
     });
 
     this.addEventListener('remove-reviewer', e => {
-      this.$.reviewers.removeAccount((e as CustomEvent).detail.reviewer);
+      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
     });
   }
 
-  override ready() {
-    super.ready();
-    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('draft')) {
+      this.draftChanged(changedProperties.get('draft') as string);
+    }
+    if (changedProperties.has('ccPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.ccPendingConfirmation);
+    }
+    if (changedProperties.has('reviewerPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.reviewerPendingConfirmation);
+    }
+    if (changedProperties.has('change')) {
+      this.computeUploader();
+      this.changeUpdated();
+    }
+    if (changedProperties.has('canBeStarted')) {
+      this.computeMessagePlaceholder();
+      this.computeSendButtonLabel();
+    }
+    if (changedProperties.has('reviewFormatting')) {
+      this.handleHeightChanged();
+    }
+    if (changedProperties.has('draftCommentThreads')) {
+      this.handleHeightChanged();
+    }
+    if (changedProperties.has('reviewers')) {
+      this.computeAllReviewers();
+    }
+    if (changedProperties.has('sendDisabled')) {
+      this.sendDisabledChanged();
+    }
+    if (changedProperties.has('attentionExpanded')) {
+      this.onAttentionExpandedChange();
+    }
+    if (
+      changedProperties.has('account') ||
+      changedProperties.has('reviewers') ||
+      changedProperties.has('ccs') ||
+      changedProperties.has('change') ||
+      changedProperties.has('draftCommentThreads') ||
+      changedProperties.has('includeComments') ||
+      changedProperties.has('labelsChanged') ||
+      changedProperties.has('draft')
+    ) {
+      this.computeNewAttention();
+    }
   }
 
   override disconnectedCallback() {
@@ -429,6 +701,498 @@
     super.disconnectedCallback();
   }
 
+  override render() {
+    if (!this.change) return;
+    this.sendDisabled = this.computeSendButtonDisabled();
+    return html`
+      <div tabindex="-1">
+        <section class="peopleContainer">
+          <gr-endpoint-decorator name="reply-reviewers">
+            <gr-endpoint-param
+              name="change"
+              .value=${this.change}
+            ></gr-endpoint-param>
+            <gr-endpoint-param name="reviewers" .value=${this.allReviewers}>
+            </gr-endpoint-param>
+            ${this.renderReviewerList()}
+            <gr-endpoint-slot name="below"></gr-endpoint-slot>
+          </gr-endpoint-decorator>
+          ${this.renderCCList()} ${this.renderReviewConfirmation()}
+        </section>
+        <section class="labelsContainer">${this.renderLabels()}</section>
+        <section class="newReplyDialog textareaContainer">
+          ${this.renderReplyText()}
+        </section>
+        ${when(
+          this.previewFormatting,
+          () => html`
+            <section class="previewContainer">
+              <gr-formatted-text
+                .content=${this.draft}
+                .config=${this.projectConfig?.commentlinks}
+              ></gr-formatted-text>
+            </section>
+          `
+        )}
+        ${this.renderDraftsSection()}
+        <div class="stickyBottom newReplyDialog">
+          <gr-endpoint-decorator name="reply-bottom">
+            <gr-endpoint-param
+              name="change"
+              .value=${this.change}
+            ></gr-endpoint-param>
+            ${this.renderAttentionSummarySection()}
+            ${this.renderAttentionDetailsSection()}
+            <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+            ${this.renderActionsSection()}
+          </gr-endpoint-decorator>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderReviewerList() {
+    return html`
+      <div class="peopleList">
+        <div class="peopleListLabel">Reviewers</div>
+        <gr-account-list
+          id="reviewers"
+          .accounts=${this.getAccountListCopy(this.reviewers)}
+          @account-added=${this.accountAdded}
+          @accounts-changed=${this.handleReviewersChanged}
+          .removableValues=${this.change?.removable_reviewers}
+          .filter=${this.filterReviewerSuggestion}
+          .pendingConfirmation=${this.reviewerPendingConfirmation}
+          @pending-confirmation-changed=${this
+            .handleReviewersConfirmationChanged}
+          .placeholder=${'Add reviewer...'}
+          @account-text-changed=${this.handleAccountTextEntry}
+          .suggestionsProvider=${this.getReviewerSuggestionsProvider(
+            this.change
+          )}
+        >
+        </gr-account-list>
+        <gr-endpoint-slot name="right"></gr-endpoint-slot>
+      </div>
+    `;
+  }
+
+  private renderCCList() {
+    return html`
+      <div class="peopleList">
+        <div class="peopleListLabel">CC</div>
+        <gr-account-list
+          id="ccs"
+          .accounts=${this.getAccountListCopy(this.ccs)}
+          @account-added=${this.accountAdded}
+          @accounts-changed=${this.handleCcsChanged}
+          .removableValues=${this.change?.removable_reviewers}
+          .filter=${this.filterCCSuggestion}
+          .pendingConfirmation=${this.ccPendingConfirmation}
+          @pending-confirmation-changed=${this.handleCcsConfirmationChanged}
+          allow-any-input
+          .placeholder=${'Add CC...'}
+          @account-text-changed=${this.handleAccountTextEntry}
+          .suggestionsProvider=${this.getCcSuggestionsProvider(this.change)}
+        >
+        </gr-account-list>
+      </div>
+    `;
+  }
+
+  private renderReviewConfirmation() {
+    return html`
+      <gr-overlay
+        id="reviewerConfirmationOverlay"
+        @iron-overlay-canceled=${this.cancelPendingReviewer}
+      >
+        <div class="reviewerConfirmation">
+          Group
+          <span class="groupName">
+            ${this.pendingConfirmationDetails?.group.name}
+          </span>
+          has
+          <span class="groupSize">
+            ${this.pendingConfirmationDetails?.count}
+          </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="reviewerConfirmationButtons">
+          <gr-button @click=${this.confirmPendingReviewer}>Yes</gr-button>
+          <gr-button @click=${this.cancelPendingReviewer}>No</gr-button>
+        </div>
+      </gr-overlay>
+    `;
+  }
+
+  private renderLabels() {
+    if (!this.change || !this.account || !this.permittedLabels) return;
+    return html`
+      <gr-endpoint-decorator name="reply-label-scores">
+        <gr-label-scores
+          id="labelScores"
+          .account=${this.account}
+          .change=${this.change}
+          @labels-changed=${this._handleLabelsChanged}
+          .permittedLabels=${this.permittedLabels}
+        ></gr-label-scores>
+        <gr-endpoint-param
+          name="change"
+          .value=${this.change}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      <div id="pluginMessage">${this.pluginMessage}</div>
+    `;
+  }
+
+  private renderReplyText() {
+    if (!this.change) return;
+    return html`
+      <div
+        class=${classMap({
+          patchsetLevelContainer: true,
+          [this.getUnresolvedPatchsetLevelClass(
+            this.isResolvedPatchsetLevelComment
+          )]: true,
+        })}
+      >
+        <gr-endpoint-decorator name="reply-text">
+          <gr-textarea
+            id="textarea"
+            class="message newReplyDialog"
+            .autocomplete=${'on'}
+            .placeholder=${this.messagePlaceholder}
+            monospace
+            ?disabled=${this.disabled}
+            .rows=${4}
+            .text=${this.draft}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.draft = e.detail.value;
+              this.handleHeightChanged();
+            }}
+          >
+          </gr-textarea>
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="labelContainer">
+          <label>
+            <input
+              id="resolvedPatchsetLevelCommentCheckbox"
+              type="checkbox"
+              ?checked=${this.isResolvedPatchsetLevelComment}
+              @change=${this.handleResolvedPatchsetLevelCommentCheckboxChanged}
+            />
+            Resolved
+          </label>
+          <label class="preview-formatting">
+            <input
+              type="checkbox"
+              ?checked=${this.previewFormatting}
+              @change=${this.handlePreviewFormattingChanged}
+            />
+            Preview formatting
+          </label>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderDraftsSection() {
+    if (this.computeHideDraftList(this.draftCommentThreads)) return;
+    return html`
+      <section class="draftsContainer">
+        <div class="includeComments">
+          <input
+            type="checkbox"
+            id="includeComments"
+            @change=${this.handleIncludeCommentsChanged}
+            ?checked=${this.includeComments}
+          />
+          <label for="includeComments"
+            >Publish ${this.computeDraftsTitle(this.draftCommentThreads)}</label
+          >
+        </div>
+        ${when(
+          this.includeComments,
+          () => html`
+            <gr-thread-list
+              id="commentList"
+              .threads=${this.draftCommentThreads!}
+              hide-dropdown
+            >
+            </gr-thread-list>
+          `
+        )}
+        <span
+          id="savingLabel"
+          class=${this.computeSavingLabelClass(this.savingComments)}
+        >
+          Saving comments...
+        </span>
+      </section>
+    `;
+  }
+
+  private renderAttentionSummarySection() {
+    if (this.attentionExpanded) return;
+    return html`
+      <section class="attention">
+        <div class="attentionSummary">
+          <div>
+            ${when(
+              this.computeShowNoAttentionUpdate(),
+              () => html` <span>${this.computeDoNotUpdateMessage()}</span> `
+            )}
+            ${when(
+              !this.computeShowNoAttentionUpdate(),
+              () => html`
+                <span>Bring to attention of</span>
+                ${this.computeNewAttentionAccounts().map(
+                  account => html`
+                    <gr-account-label
+                      .account=${account}
+                      .forceAttention=${this.computeHasNewAttention(account)}
+                      .selected=${this.computeHasNewAttention(account)}
+                      .hideHovercard=${true}
+                      .selectionChipStyle=${true}
+                      @click=${this.handleAttentionClick}
+                    ></gr-account-label>
+                  `
+                )}
+              `
+            )}
+            <gr-tooltip-content
+              has-tooltip
+              title=${this.computeAttentionButtonTitle()}
+            >
+              <gr-button
+                class="edit-attention-button"
+                @click=${this.handleAttentionModify}
+                ?disabled=${this.sendDisabled}
+                link
+                position-below
+                data-label="Edit"
+                data-action-type="change"
+                data-action-key="edit"
+                role="button"
+                tabindex="0"
+              >
+                <iron-icon icon="gr-icons:edit"></iron-icon>
+                Modify
+              </gr-button>
+            </gr-tooltip-content>
+          </div>
+          <div>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+        </div>
+      </section>
+    `;
+  }
+
+  private renderAttentionDetailsSection() {
+    if (!this.attentionExpanded) return;
+    return html`
+      <section class="attention-detail">
+        <div class="attentionDetailsTitle">
+          <div>
+            <span>Modify attention to</span>
+          </div>
+          <div></div>
+          <div>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+        </div>
+        <div class="selectUsers">
+          <span
+            >Select chips to set who will be in the attention set after sending
+            this reply</span
+          >
+        </div>
+        <div class="peopleList">
+          <div class="peopleListLabel">Owner</div>
+          <div class="peopleListValues">
+            <gr-account-label
+              .account=${this.owner}
+              ?forceAttention=${this.computeHasNewAttention(this.owner)}
+              .selected=${this.computeHasNewAttention(this.owner)}
+              .hideHovercard=${true}
+              .selectionChipStyle=${true}
+              @click=${this.handleAttentionClick}
+            >
+            </gr-account-label>
+          </div>
+        </div>
+        ${when(
+          this.uploader,
+          () => html`
+            <div class="peopleList">
+              <div class="peopleListLabel">Uploader</div>
+              <div class="peopleListValues">
+                <gr-account-label
+                  .account=${this.uploader}
+                  ?forceAttention=${this.computeHasNewAttention(this.uploader)}
+                  .selected=${this.computeHasNewAttention(this.uploader)}
+                  .hideHovercard=${true}
+                  .selectionChipStyle=${true}
+                  @click=${this.handleAttentionClick}
+                >
+                </gr-account-label>
+              </div>
+            </div>
+          `
+        )}
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <div class="peopleListValues">
+            ${this.removeServiceUsers(this.reviewers).map(
+              account => html`
+                <gr-account-label
+                  .account=${account}
+                  ?forceAttention=${this.computeHasNewAttention(account)}
+                  .selected=${this.computeHasNewAttention(account)}
+                  .hideHovercard=${true}
+                  .selectionChipStyle=${true}
+                  @click=${this.handleAttentionClick}
+                >
+                </gr-account-label>
+              `
+            )}
+          </div>
+        </div>
+
+        ${when(
+          this.attentionCcsCount,
+          () => html`
+            <div class="peopleList">
+              <div class="peopleListLabel">CC</div>
+              <div class="peopleListValues">
+                ${this.removeServiceUsers(this.ccs).map(
+                  account => html`
+                    <gr-account-label
+                      .account=${account}
+                      ?forceAttention=${this.computeHasNewAttention(account)}
+                      .selected=${this.computeHasNewAttention(account)}
+                      .hideHovercard=${true}
+                      .selectionChipStyle=${true}
+                      @click=${this.handleAttentionClick}
+                    >
+                    </gr-account-label>
+                  `
+                )}
+              </div>
+            </div>
+          `
+        )}
+        ${when(
+          this.computeShowAttentionTip(
+            this.account,
+            this.owner,
+            this.currentAttentionSet,
+            this.newAttentionSet
+          ),
+          () => html`
+            <div class="attentionTip">
+              <iron-icon
+                class="pointer"
+                icon="gr-icons:lightbulb-outline"
+              ></iron-icon>
+              Be mindful of requiring attention from too many users.
+            </div>
+          `
+        )}
+      </section>
+    `;
+  }
+
+  private renderActionsSection() {
+    return html`
+      <section class="actions">
+        <div class="left">
+          ${when(
+            this.knownLatestState === LatestPatchState.CHECKING,
+            () => html`
+              <span id="checkingStatusLabel">
+                Checking whether patch ${this.patchNum} is latest...
+              </span>
+            `
+          )}
+          ${when(
+            this.knownLatestState === LatestPatchState.NOT_LATEST,
+            () => html`
+              <span id="notLatestLabel">
+                ${this.computePatchSetWarning()}
+                <gr-button link @click=${this._reload}>Reload</gr-button>
+              </span>
+            `
+          )}
+        </div>
+        <div class="right">
+          <gr-button
+            link
+            id="cancelButton"
+            class="action cancel"
+            @click=${this.cancelTapHandler}
+            >Cancel</gr-button
+          >
+          ${when(
+            this.canBeStarted,
+            () => html`
+              <!-- Use 'Send' here as the change may only about reviewers / ccs
+            and when this button is visible, the next button will always
+            be 'Start review' -->
+              <gr-tooltip-content has-tooltip title=${this.saveTooltip}>
+                <gr-button
+                  link
+                  ?disabled=${this.knownLatestState ===
+                  LatestPatchState.NOT_LATEST}
+                  class="action save"
+                  @click=${this.saveClickHandler}
+                  >Send As WIP</gr-button
+                >
+              </gr-tooltip-content>
+            `
+          )}
+          <gr-tooltip-content
+            has-tooltip
+            title=${this.computeSendButtonTooltip(
+              this.canBeStarted,
+              this.commentEditing
+            )}
+          >
+            <gr-button
+              id="sendButton"
+              primary
+              ?disabled=${this.sendDisabled}
+              class="action send"
+              @click=${this.sendTapHandler}
+              >${this.sendButtonLabel}
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+      </section>
+    `;
+  }
+
   /**
    * Note that this method is not actually *opening* the dialog. Opening and
    * showing the dialog is dealt with by the overlay. This method is used by the
@@ -446,47 +1210,57 @@
           : LatestPatchState.NOT_LATEST;
       });
 
-    this._focusOn(focusTarget);
+    this.focusOn(focusTarget);
     if (quote?.length) {
       // If a reply quote has been provided, use it.
       this.draft = quote;
     } else {
       // Otherwise, check for an unsaved draft in localstorage.
-      this.draft = this._loadStoredDraft();
+      this.draft = this.loadStoredDraft();
     }
     if (this.restApiService.hasPendingDiffDrafts()) {
-      this._savingComments = true;
+      this.savingComments = true;
       this.restApiService.awaitPendingDiffDrafts().then(() => {
         fireEvent(this, 'comment-refresh');
-        this._savingComments = false;
+        this.savingComments = false;
       });
     }
   }
 
-  _computeHasDrafts(
-    draft: string,
-    draftCommentThreads: PolymerDeepPropertyChange<
-      CommentThread[] | undefined,
-      CommentThread[] | undefined
-    >
-  ) {
-    if (draftCommentThreads.base === undefined) return false;
-    return draft.length > 0 || draftCommentThreads.base.length > 0;
+  hasDrafts() {
+    if (this.draftCommentThreads === undefined) return false;
+    return this.draft.length > 0 || this.draftCommentThreads.length > 0;
   }
 
   override focus() {
-    this._focusOn(FocusTarget.ANY);
+    this.focusOn(FocusTarget.ANY);
   }
 
   getFocusStops() {
-    const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
+    const end = this.sendDisabled ? this.cancelButton : this.sendButton;
+    if (!this.reviewersList?.focusStart || !end) return undefined;
     return {
-      start: this.$.reviewers.focusStart,
+      start: this.reviewersList.focusStart,
       end,
     };
   }
 
-  setLabelValue(label: string, value: string) {
+  private handleResolvedPatchsetLevelCommentCheckboxChanged(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    this.isResolvedPatchsetLevelComment = e.target.checked;
+  }
+
+  private handlePreviewFormattingChanged(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    this.previewFormatting = e.target.checked;
+  }
+
+  private handleIncludeCommentsChanged(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    this.includeComments = e.target.checked;
+  }
+
+  setLabelValue(label: string, value: string): void {
     const selectorEl =
       this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>(
         `gr-label-score-row[name="${label}"]`
@@ -502,46 +1276,27 @@
     return selectorEl?.selectedValue;
   }
 
-  @observe('_ccs.splices')
-  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
-    this._reviewerTypeChanged(splices, ReviewerType.CC);
-  }
-
-  @observe('_reviewers.splices')
-  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
-    this._reviewerTypeChanged(splices, ReviewerType.REVIEWER);
-  }
-
-  _reviewerTypeChanged(
-    splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>,
-    reviewerType: ReviewerType
-  ) {
-    if (splices && splices.indexSplices) {
-      this._reviewersMutated = true;
-      let key: AccountId | EmailAddress | GroupId | undefined;
-      let index;
-      let account;
+  accountAdded(e: CustomEvent<AccountInputDetail>) {
+    const account = e.detail.account;
+    const key = accountOrGroupKey(account);
+    const reviewerType =
+      (e.target as GrAccountList).getAttribute('id') === 'ccs'
+        ? ReviewerType.CC
+        : ReviewerType.REVIEWER;
+    const isReviewer = ReviewerType.REVIEWER === reviewerType;
+    const array = isReviewer ? this.ccs : this.reviewers;
+    const index = array.findIndex(
+      reviewer => accountOrGroupKey(reviewer) === key
+    );
+    if (index >= 0) {
       // Remove any accounts that already exist as a CC for reviewer
       // or vice versa.
-      const isReviewer = ReviewerType.REVIEWER === reviewerType;
-      for (const splice of splices.indexSplices) {
-        for (let i = 0; i < splice.addedCount; i++) {
-          account = splice.object[splice.index + i];
-          key = accountOrGroupKey(account);
-          const array = isReviewer ? this._ccs : this._reviewers;
-          index = array.findIndex(
-            account => accountOrGroupKey(account) === key
-          );
-          if (index >= 0) {
-            this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
-            const moveFrom = isReviewer ? 'CC' : 'reviewer';
-            const moveTo = isReviewer ? 'reviewer' : 'CC';
-            const id = account.name || key;
-            const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
-            fireAlert(this, message);
-          }
-        }
-      }
+      array.splice(index, 1);
+      const moveFrom = isReviewer ? 'CC' : 'reviewer';
+      const moveTo = isReviewer ? 'reviewer' : 'CC';
+      const id = account.name || key;
+      const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
+      fireAlert(this, message);
     }
   }
 
@@ -561,33 +1316,27 @@
         reviewers.push(reviewer);
       });
     };
-    addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER);
-    addToReviewInput(this.$.ccs.additions(), ReviewerState.CC);
+    addToReviewInput(this.reviewersList!.additions(), ReviewerState.REVIEWER);
+    addToReviewInput(this.ccsList!.additions(), ReviewerState.CC);
     addToReviewInput(
-      this.$.reviewers.removals().filter(
+      this.reviewersList!.removals().filter(
         r =>
           isReviewerOrCC(change, r) &&
           // ignore removal from reviewer request if being added to CC
-          !this.$.ccs
-            .additions()
-            .some(
-              account =>
-                mapReviewer(account).reviewer === mapReviewer(r).reviewer
-            )
+          !this.ccsList!.additions().some(
+            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
+          )
       ),
       ReviewerState.REMOVED
     );
     addToReviewInput(
-      this.$.ccs.removals().filter(
+      this.ccsList!.removals().filter(
         r =>
           isReviewerOrCC(change, r) &&
           // ignore removal from CC request if being added as reviewer
-          !this.$.reviewers
-            .additions()
-            .some(
-              account =>
-                mapReviewer(account).reviewer === mapReviewer(r).reviewer
-            )
+          !this.reviewersList!.additions().some(
+            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
+          )
       ),
       ReviewerState.REMOVED
     );
@@ -609,23 +1358,23 @@
       reviewInput.ready = true;
     }
 
-    const reason = getReplyByReason(this._account, this.serverConfig);
+    const reason = getReplyByReason(this.account, this.serverConfig);
 
     reviewInput.ignore_automatic_attention_set_rules = true;
     reviewInput.add_to_attention_set = [];
-    for (const user of this._newAttentionSet) {
-      if (!this._currentAttentionSet.has(user)) {
+    for (const user of this.newAttentionSet) {
+      if (!this.currentAttentionSet.has(user)) {
         reviewInput.add_to_attention_set.push({user, reason});
       }
     }
     reviewInput.remove_from_attention_set = [];
-    for (const user of this._currentAttentionSet) {
-      if (!this._newAttentionSet.has(user)) {
+    for (const user of this.currentAttentionSet) {
+      if (!this.newAttentionSet.has(user)) {
         reviewInput.remove_from_attention_set.push({user, reason});
       }
     }
     this.reportAttentionSetChanges(
-      this._attentionExpanded,
+      this.attentionExpanded,
       reviewInput.add_to_attention_set,
       reviewInput.remove_from_attention_set
     );
@@ -633,7 +1382,7 @@
     if (this.draft) {
       const comment: CommentInput = {
         message: this.draft,
-        unresolved: !this._isResolvedPatchsetLevelComment,
+        unresolved: !this.isResolvedPatchsetLevelComment,
       };
       reviewInput.comments = {
         [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
@@ -644,8 +1393,8 @@
     reviewInput.reviewers = this.computeReviewers(this.change);
     this.disabled = true;
 
-    const errFn = (r?: Response | null) => this._handle400Error(r);
-    return this._saveReview(reviewInput, errFn)
+    const errFn = (r?: Response | null) => this.handle400Error(r);
+    return this.saveReview(reviewInput, errFn)
       .then(response => {
         if (!response) {
           // Null or undefined response indicates that an error handler
@@ -658,7 +1407,7 @@
         }
 
         this.draft = '';
-        this._includeComments = true;
+        this.includeComments = true;
         this.dispatchEvent(
           new CustomEvent('send', {
             composed: true,
@@ -678,31 +1427,31 @@
       });
   }
 
-  _focusOn(section?: FocusTarget) {
+  focusOn(section?: FocusTarget) {
     // Safeguard- always want to focus on something.
     if (!section || section === FocusTarget.ANY) {
-      section = this._chooseFocusTarget();
+      section = this.chooseFocusTarget();
     }
     if (section === FocusTarget.BODY) {
       const textarea = queryAndAssert<GrTextarea>(this, 'gr-textarea');
       setTimeout(() => textarea.getNativeTextarea().focus());
     } else if (section === FocusTarget.REVIEWERS) {
-      const reviewerEntry = this.$.reviewers.focusStart;
-      setTimeout(() => reviewerEntry.focus());
+      const reviewerEntry = this.reviewersList?.focusStart;
+      setTimeout(() => reviewerEntry?.focus());
     } else if (section === FocusTarget.CCS) {
-      const ccEntry = this.$.ccs.focusStart;
-      setTimeout(() => ccEntry.focus());
+      const ccEntry = this.ccsList?.focusStart;
+      setTimeout(() => ccEntry?.focus());
     }
   }
 
-  _chooseFocusTarget() {
+  chooseFocusTarget() {
     // If we are the owner and the reviewers field is empty, focus on that.
     if (
-      this._account &&
+      this.account &&
       this.change &&
       this.change.owner &&
-      this._account._account_id === this.change.owner._account_id &&
-      (!this._reviewers || this._reviewers.length === 0)
+      this.account._account_id === this.change.owner._account_id &&
+      (!this.reviewers || this.reviewers?.length === 0)
     ) {
       return FocusTarget.REVIEWERS;
     }
@@ -711,15 +1460,15 @@
     return FocusTarget.BODY;
   }
 
-  _isOwner(account?: AccountInfo, change?: ChangeInfo) {
+  isOwner(account?: AccountInfo, change?: ChangeInfo) {
     if (!account || !change || !change.owner) return false;
     return account._account_id === change.owner._account_id;
   }
 
-  _handle400Error(r?: Response | null) {
+  handle400Error(r?: Response | null) {
     if (!r) throw new Error('Response is empty.');
     let response: Response = r;
-    // A call to _saveReview could fail with a server error if erroneous
+    // A call to saveReview could fail with a server error if erroneous
     // reviewers were requested. This is signalled with a 400 Bad Request
     // status. The default gr-rest-api error handling would result in a large
     // JSON response body being displayed to the user in the gr-error-manager
@@ -738,7 +1487,7 @@
     const jsonPromise = this.restApiService.getResponseObject(response.clone());
     return jsonPromise.then((parsed: ParsedJSON) => {
       const result = parsed as ReviewResult;
-      // Only perform custom error handling for 400s and a parseable
+      // Only perform custom error handling for 400s and a parsable
       // ReviewResult response.
       if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
@@ -754,45 +1503,42 @@
     });
   }
 
-  _computeHideDraftList(draftCommentThreads?: CommentThread[]) {
+  computeHideDraftList(draftCommentThreads?: CommentThread[]) {
     return !draftCommentThreads || draftCommentThreads.length === 0;
   }
 
-  _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
+  computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
     return pluralize(total, 'Draft');
   }
 
-  _computeMessagePlaceholder(canBeStarted: boolean) {
-    return canBeStarted
+  computeMessagePlaceholder() {
+    this.messagePlaceholder = this.canBeStarted
       ? 'Add a note for your reviewers...'
       : 'Say something nice...';
   }
 
-  @observe('change.reviewers.*', 'change.owner')
-  _changeUpdated(
-    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo
-  ) {
-    if (changeRecord === undefined || owner === undefined) return;
-    this._rebuildReviewerArrays(changeRecord.base, owner);
+  changeUpdated() {
+    if (this.change === undefined) return;
+    this.rebuildReviewerArrays();
   }
 
-  _rebuildReviewerArrays(changeReviewers: Reviewers, owner: AccountInfo) {
-    this._owner = owner;
+  rebuildReviewerArrays() {
+    if (!this.change?.owner || !this.change?.reviewers) return;
+    this.owner = this.change.owner;
 
-    const reviewers = [];
-    const ccs = [];
+    const reviewers: AccountInput[] = [];
+    const ccs: AccountInput[] = [];
 
-    if (changeReviewers) {
-      for (const key of Object.keys(changeReviewers)) {
+    if (this.change.reviewers) {
+      for (const key of Object.keys(this.change.reviewers)) {
         if (key !== 'REVIEWER' && key !== 'CC') {
           this.reporting.error(new Error(`Unexpected reviewer state: ${key}`));
           continue;
         }
-        if (!changeReviewers[key]) continue;
-        for (const entry of changeReviewers[key]!) {
-          if (entry._account_id === owner._account_id) {
+        if (!this.change.reviewers[key]) continue;
+        for (const entry of this.change.reviewers[key]!) {
+          if (entry._account_id === this.owner._account_id) {
             continue;
           }
           switch (key) {
@@ -807,172 +1553,141 @@
       }
     }
 
-    this._ccs = ccs;
-    this._reviewers = reviewers;
+    this.ccs = ccs;
+    this.reviewers = reviewers;
   }
 
-  _handleAttentionModify() {
-    this._attentionExpanded = true;
+  handleAttentionModify() {
+    this.attentionExpanded = true;
   }
 
-  @observe('_attentionExpanded')
-  _onAttentionExpandedChange() {
+  onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
     fireEvent(this, 'iron-resize');
   }
 
-  _showAttentionSummary(attentionExpanded?: boolean) {
-    return !attentionExpanded;
-  }
-
-  _showAttentionDetails(attentionExpanded?: boolean) {
-    return attentionExpanded;
-  }
-
-  _computeAttentionButtonTitle(sendDisabled?: boolean) {
+  computeAttentionButtonTitle(sendDisabled?: boolean) {
     return sendDisabled
       ? 'Modify the attention set by adding a comment or use the account ' +
           'hovercard in the change page.'
       : 'Edit attention set changes';
   }
 
-  _handleAttentionClick(e: Event) {
+  handleAttentionClick(e: Event) {
     const id = (e.target as GrAccountChip)?.account?._account_id;
     if (!id) return;
 
-    const selfId = (this._account && this._account._account_id) || -1;
+    const selfId = (this.account && this.account._account_id) || -1;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
     const self = id === selfId ? '_SELF' : '';
-    const role = id === ownerId ? '_OWNER' : '_REVIEWER';
+    const role = id === ownerId ? 'OWNER' : '_REVIEWER';
 
-    if (this._newAttentionSet.has(id)) {
-      this._newAttentionSet.delete(id);
+    if (this.newAttentionSet.has(id)) {
+      this.newAttentionSet.delete(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
-      this._newAttentionSet.add(id);
+      this.newAttentionSet.add(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
     }
 
     // Ensure that Polymer picks up the change.
-    this._newAttentionSet = new Set(this._newAttentionSet);
+    this.newAttentionSet = new Set(this.newAttentionSet);
   }
 
-  _computeHasNewAttention(
-    account?: AccountInfo,
-    newAttention?: Set<AccountId>
-  ) {
-    return (
-      newAttention &&
+  computeHasNewAttention(account?: AccountInfo) {
+    return !!(
       account &&
       account._account_id &&
-      newAttention.has(account._account_id)
+      this.newAttentionSet?.has(account._account_id)
     );
   }
 
-  @observe(
-    '_account',
-    '_reviewers.*',
-    '_ccs.*',
-    'change',
-    'draftCommentThreads',
-    '_includeComments',
-    '_labelsChanged',
-    'hasDrafts'
-  )
-  _computeNewAttention(
-    currentUser?: AccountInfo,
-    reviewers?: PolymerDeepPropertyChange<
-      AccountInfoInput[],
-      AccountInfoInput[]
-    >,
-    ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
-    change?: ChangeInfo,
-    draftCommentThreads?: CommentThread[],
-    includeComments?: boolean,
-    _labelsChanged?: boolean,
-    hasDrafts?: boolean
-  ) {
+  computeNewAttention() {
     if (
-      currentUser === undefined ||
-      currentUser._account_id === undefined ||
-      reviewers === undefined ||
-      ccs === undefined ||
-      change === undefined ||
-      draftCommentThreads === undefined ||
-      includeComments === undefined
+      this.account?._account_id === undefined ||
+      this.change === undefined ||
+      this.includeComments === undefined ||
+      this.draftCommentThreads === undefined
     ) {
       return;
     }
     // The draft comments are only relevant for the attention set as long as the
     // user actually plans to publish their drafts.
-    draftCommentThreads = includeComments ? draftCommentThreads : [];
-    const hasVote = !!_labelsChanged;
-    const isOwner = this._isOwner(currentUser, change);
-    const isUploader = this._uploader?._account_id === currentUser._account_id;
-    this._attentionCcsCount = removeServiceUsers(ccs.base).length;
-    this._currentAttentionSet = new Set(
-      Object.keys(change.attention_set || {}).map(id => Number(id) as AccountId)
+    const draftCommentThreads = this.includeComments
+      ? this.draftCommentThreads
+      : [];
+    const hasVote = !!this.labelsChanged;
+    const isOwner = this.isOwner(this.account, this.change);
+    const isUploader = this.uploader?._account_id === this.account._account_id;
+    this.attentionCcsCount = removeServiceUsers(this.ccs).length;
+    this.currentAttentionSet = new Set(
+      Object.keys(this.change.attention_set || {}).map(
+        id => Number(id) as AccountId
+      )
     );
-    const newAttention = new Set(this._currentAttentionSet);
-    if (change.status === ChangeStatus.NEW) {
+    const newAttention = new Set(this.currentAttentionSet);
+    if (this.change.status === ChangeStatus.NEW) {
       // Add everyone that the user is replying to in a comment thread.
-      this._computeCommentAccounts(draftCommentThreads).forEach(id =>
+      this.computeCommentAccounts(draftCommentThreads).forEach(id =>
         newAttention.add(id)
       );
       // Remove the current user.
-      newAttention.delete(currentUser._account_id);
+      newAttention.delete(this.account._account_id);
       // Add all new reviewers, but not the current reviewer, if they are also
       // sending a draft or a label vote.
       const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
-        !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
-      reviewers.base
-        .filter(r => r._account_id)
+        !(
+          r._account_id === this.account!._account_id &&
+          (this.hasDrafts() || hasVote)
+        );
+      this.reviewers
+        .filter(r => isAccount(r))
         .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
         .filter(notIsReviewerAndHasDraftOrLabel)
-        .forEach(r => newAttention.add(r._account_id!));
+        .forEach(r => newAttention.add((r as AccountInfo)._account_id!));
       // Add owner and uploader, if someone else replies.
-      if (hasDrafts || hasVote) {
-        if (this._uploader?._account_id && !isUploader) {
-          newAttention.add(this._uploader._account_id);
+      if (this.hasDrafts() || hasVote) {
+        if (this.uploader?._account_id && !isUploader) {
+          newAttention.add(this.uploader._account_id);
         }
-        if (change.owner?._account_id && !isOwner) {
-          newAttention.add(change.owner._account_id);
+        if (this.change.owner?._account_id && !isOwner) {
+          newAttention.add(this.change.owner._account_id);
         }
       }
     } else {
       // The only reason for adding someone to the attention set for merged or
       // abandoned changes is that someone makes a comment thread unresolved.
       const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
-      if (change.owner && hasUnresolvedDraft) {
-        // A change owner must have an _account_id.
-        newAttention.add(change.owner._account_id!);
+      if (this.change.owner && hasUnresolvedDraft) {
+        // A change owner must have an account_id.
+        newAttention.add(this.change.owner._account_id!);
       }
       // Remove the current user.
-      newAttention.delete(currentUser._account_id);
+      newAttention.delete(this.account._account_id);
     }
     // Finally make sure that everyone in the attention set is still active as
     // owner, reviewer or cc.
-    const allAccountIds = this._allAccounts()
+    const allAccountIds = this.allAccounts()
       .map(a => a._account_id)
       .filter(id => !!id);
-    this._newAttentionSet = new Set(
+    this.newAttentionSet = new Set(
       [...newAttention].filter(id => allAccountIds.includes(id))
     );
-    this._attentionExpanded = this._computeShowAttentionTip(
-      currentUser,
-      change.owner,
-      this._currentAttentionSet,
-      this._newAttentionSet
+    this.attentionExpanded = this.computeShowAttentionTip(
+      this.account,
+      this.change.owner,
+      this.currentAttentionSet,
+      this.newAttentionSet
     );
   }
 
-  _computeShowAttentionTip(
+  computeShowAttentionTip(
     currentUser?: AccountInfo,
     owner?: AccountInfo,
     currentAttentionSet?: Set<AccountId>,
@@ -987,7 +1702,7 @@
     return isOwner && addedIds.length > 2;
   }
 
-  _computeCommentAccounts(threads: CommentThread[]) {
+  computeCommentAccounts(threads: CommentThread[]) {
     const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW];
     const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
@@ -995,7 +1710,7 @@
       const unresolved = isUnresolved(thread);
       thread.comments.forEach(comment => {
         if (comment.author) {
-          // A comment author must have an _account_id.
+          // A comment author must have an account_id.
           const authorId = comment.author._account_id!;
           const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
           if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
@@ -1005,110 +1720,93 @@
     return accountIds;
   }
 
-  _computeShowNoAttentionUpdate(
-    config?: ServerInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>,
-    sendDisabled?: boolean
-  ) {
-    return (
-      sendDisabled ||
-      this._computeNewAttentionAccounts(
-        config,
-        currentAttentionSet,
-        newAttentionSet
-      ).length === 0
-    );
+  computeShowNoAttentionUpdate() {
+    return this.sendDisabled || this.computeNewAttentionAccounts().length === 0;
   }
 
-  _computeDoNotUpdateMessage(
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>,
-    sendDisabled?: boolean
-  ) {
-    if (!currentAttentionSet || !newAttentionSet) return '';
-    if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+  computeDoNotUpdateMessage() {
+    if (!this.currentAttentionSet || !this.newAttentionSet) return '';
+    if (
+      this.sendDisabled ||
+      areSetsEqual(this.currentAttentionSet, this.newAttentionSet)
+    ) {
       return 'No changes to the attention set.';
     }
-    if (containsAll(currentAttentionSet, newAttentionSet)) {
+    if (containsAll(this.currentAttentionSet, this.newAttentionSet)) {
       return 'No additions to the attention set.';
     }
     this.reporting.error(
       new Error(
-        '_computeDoNotUpdateMessage()' +
+        'computeDoNotUpdateMessage()' +
           'should not be called when users were added to the attention set.'
       )
     );
     return '';
   }
 
-  _computeNewAttentionAccounts(
-    _?: ServerInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>
-  ) {
-    if (currentAttentionSet === undefined || newAttentionSet === undefined) {
+  computeNewAttentionAccounts(): AccountInfo[] {
+    if (
+      this.currentAttentionSet === undefined ||
+      this.newAttentionSet === undefined
+    ) {
       return [];
     }
-    return [...newAttentionSet]
-      .filter(id => !currentAttentionSet.has(id))
-      .map(id => this._findAccountById(id))
-      .filter(account => !!account);
+    return [...this.newAttentionSet]
+      .filter(id => !this.currentAttentionSet.has(id))
+      .map(id => this.findAccountById(id))
+      .filter(account => !!account) as AccountInfo[];
   }
 
-  _findAccountById(accountId: AccountId) {
-    return this._allAccounts().find(r => r._account_id === accountId);
+  findAccountById(accountId: AccountId) {
+    return this.allAccounts().find(r => r._account_id === accountId);
   }
 
-  _allAccounts() {
+  allAccounts() {
     let allAccounts: (AccountInfoInput | GroupInfoInput)[] = [];
     if (this.change && this.change.owner) allAccounts.push(this.change.owner);
-    if (this._uploader) allAccounts.push(this._uploader);
-    if (this._reviewers) allAccounts = [...allAccounts, ...this._reviewers];
-    if (this._ccs) allAccounts = [...allAccounts, ...this._ccs];
+    if (this.uploader) allAccounts.push(this.uploader);
+    if (this.reviewers) allAccounts = [...allAccounts, ...this.reviewers];
+    if (this.ccs) allAccounts = [...allAccounts, ...this.ccs];
     return removeServiceUsers(allAccounts.filter(isAccount));
   }
 
-  /**
-   * The newAttentionSet param is only used to force re-computation.
-   */
-  _removeServiceUsers(accounts: AccountInfo[], _: Set<AccountId>) {
+  removeServiceUsers(accounts: AccountInfo[]) {
     return removeServiceUsers(accounts);
   }
 
-  _computeUploader(change: ChangeInfo) {
+  computeUploader() {
     if (
-      !change ||
-      !change.current_revision ||
-      !change.revisions ||
-      !change.revisions[change.current_revision]
+      !this.change?.current_revision ||
+      !this.change?.revisions?.[this.change.current_revision]
     ) {
-      return undefined;
+      this.uploader = undefined;
+      return;
     }
-    const rev = change.revisions[change.current_revision];
+    const rev = this.change.revisions[this.change.current_revision];
 
     if (
       !rev.uploader ||
-      change.owner._account_id === rev.uploader._account_id
+      this.change?.owner._account_id === rev.uploader._account_id
     ) {
-      return undefined;
+      this.uploader = undefined;
+      return;
     }
-    return rev.uploader;
+    this.uploader = rev.uploader;
   }
 
   /**
    * Generates a function to filter out reviewer/CC entries. When isCCs is
-   * truthy, the function filters out entries that already exist in this._ccs.
-   * When falsy, the function filters entries that exist in this._reviewers.
+   * truthy, the function filters out entries that already exist in this.ccs.
+   * When falsy, the function filters entries that exist in this.reviewers.
    */
-  _filterReviewerSuggestionGenerator(
+  filterReviewerSuggestionGenerator(
     isCCs: boolean
   ): (input: Suggestion) => boolean {
     return suggestion => {
       let entry: AccountInfo | GroupInfo;
       if (isReviewerAccountSuggestion(suggestion)) {
         entry = suggestion.account;
-        if (entry._account_id === this._owner?._account_id) {
+        if (entry._account_id === this.owner?._account_id) {
           return false;
         }
       } else if (isReviewerGroupSuggestion(suggestion)) {
@@ -1124,24 +1822,20 @@
       const finder = (entry: AccountInfo | GroupInfo) =>
         accountOrGroupKey(entry) === key;
       if (isCCs) {
-        return this._ccs.find(finder) === undefined;
+        return this.ccs.find(finder) === undefined;
       }
-      return this._reviewers.find(finder) === undefined;
+      return this.reviewers.find(finder) === undefined;
     };
   }
 
-  _getAccount() {
-    return this.restApiService.getAccount();
-  }
-
-  _cancelTapHandler(e: Event) {
+  cancelTapHandler(e: Event) {
     e.preventDefault();
     this.cancel();
   }
 
   cancel() {
     assertIsDefined(this.change, 'change');
-    if (!this._owner) throw new Error('missing required _owner property');
+    if (!this.owner) throw new Error('missing required owner property');
     this.dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
@@ -1149,36 +1843,36 @@
       })
     );
     queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
-    this.$.reviewers.clearPendingRemovals();
-    this._rebuildReviewerArrays(this.change.reviewers, this._owner);
+    this.reviewersList?.clearPendingRemovals();
+    this.rebuildReviewerArrays();
   }
 
-  _saveClickHandler(e: Event) {
+  saveClickHandler(e: Event) {
     e.preventDefault();
-    if (!this.$.ccs.submitEntryText()) {
+    if (!this.ccsList?.submitEntryText()) {
       // Do not proceed with the save if there is an invalid email entry in
       // the text field of the CC entry.
       return;
     }
-    this.send(this._includeComments, false);
+    this.send(this.includeComments, false);
   }
 
-  _sendTapHandler(e: Event) {
+  sendTapHandler(e: Event) {
     e.preventDefault();
-    this._submit();
+    this.submit();
   }
 
-  _submit() {
-    if (!this.$.ccs.submitEntryText()) {
+  submit() {
+    if (!this.ccsList?.submitEntryText()) {
       // Do not proceed with the send if there is an invalid email entry in
       // the text field of the CC entry.
       return;
     }
-    if (this._sendDisabled) {
+    if (this.sendDisabled) {
       fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
-    return this.send(this._includeComments, this.canBeStarted).catch(err => {
+    return this.send(this.includeComments, this.canBeStarted).catch(err => {
       this.dispatchEvent(
         new CustomEvent('show-error', {
           bubbles: true,
@@ -1189,7 +1883,7 @@
     });
   }
 
-  _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
+  saveReview(review: ReviewInput, errFn?: ErrorCallback) {
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.patchNum, 'patchNum');
     return this.restApiService.saveChangeReview(
@@ -1200,43 +1894,43 @@
     );
   }
 
-  _reviewerPendingConfirmationUpdated(reviewer: RawAccountInput | null) {
+  pendingConfirmationUpdated(reviewer: RawAccountInput | null) {
     if (reviewer === null) {
-      this.$.reviewerConfirmationOverlay.close();
+      this.reviewerConfirmationOverlay?.close();
     } else {
-      this._pendingConfirmationDetails =
-        this._ccPendingConfirmation || this._reviewerPendingConfirmation;
-      this.$.reviewerConfirmationOverlay.open();
+      this.pendingConfirmationDetails =
+        this.ccPendingConfirmation || this.reviewerPendingConfirmation;
+      this.reviewerConfirmationOverlay?.open();
     }
   }
 
-  _confirmPendingReviewer() {
-    if (this._ccPendingConfirmation) {
-      this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
-      this._focusOn(FocusTarget.CCS);
+  confirmPendingReviewer() {
+    if (this.ccPendingConfirmation) {
+      this.ccsList?.confirmGroup(this.ccPendingConfirmation.group);
+      this.focusOn(FocusTarget.CCS);
       return;
     }
-    if (this._reviewerPendingConfirmation) {
-      this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
-      this._focusOn(FocusTarget.REVIEWERS);
+    if (this.reviewerPendingConfirmation) {
+      this.reviewersList?.confirmGroup(this.reviewerPendingConfirmation.group);
+      this.focusOn(FocusTarget.REVIEWERS);
       return;
     }
     this.reporting.error(
-      new Error('_confirmPendingReviewer called without pending confirm')
+      new Error('confirmPendingReviewer called without pending confirm')
     );
   }
 
-  _cancelPendingReviewer() {
-    this._ccPendingConfirmation = null;
-    this._reviewerPendingConfirmation = null;
+  cancelPendingReviewer() {
+    this.ccPendingConfirmation = null;
+    this.reviewerPendingConfirmation = null;
 
-    const target = this._ccPendingConfirmation
+    const target = this.ccPendingConfirmation
       ? FocusTarget.CCS
       : FocusTarget.REVIEWERS;
-    this._focusOn(target);
+    this.focusOn(target);
   }
 
-  _getStorageLocation(): StorageLocation {
+  getStorageLocation(): StorageLocation {
     assertIsDefined(this.change, 'change');
     return {
       changeNum: this.change._number,
@@ -1245,50 +1939,73 @@
     };
   }
 
-  _loadStoredDraft() {
-    const draft = this.storage.getDraftComment(this._getStorageLocation());
+  loadStoredDraft() {
+    const draft = this.storage.getDraftComment(this.getStorageLocation());
     return draft?.message ?? '';
   }
 
-  _handleAccountTextEntry() {
+  handleAccountTextEntry() {
     // When either of the account entries has input added to the autocomplete,
     // it should trigger the save button to enable/
     //
     // Note: if the text is removed, the save button will not get disabled.
-    this._reviewersMutated = true;
+    this.reviewersMutated = true;
   }
 
-  _draftChanged(newDraft: string, oldDraft?: string) {
+  draftChanged(oldDraft: string) {
     this.storeTask = debounce(
       this.storeTask,
       () => {
-        if (!newDraft.length && oldDraft) {
+        if (!this.draft.length && oldDraft) {
           // If the draft has been modified to be empty, then erase the storage
           // entry.
-          this.storage.eraseDraftComment(this._getStorageLocation());
-        } else if (newDraft.length) {
-          this.storage.setDraftComment(this._getStorageLocation(), this.draft);
+          this.storage.eraseDraftComment(this.getStorageLocation());
+        } else if (this.draft.length) {
+          this.storage.setDraftComment(this.getStorageLocation(), this.draft);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
     );
   }
 
-  _handleHeightChanged() {
+  handleHeightChanged() {
     fireEvent(this, 'autogrow');
   }
 
-  getLabelScores() {
-    return this.$.labelScores || queryAndAssert(this, 'gr-label-scores');
+  getLabelScores(): GrLabelScores {
+    return this.labelScores || queryAndAssert(this, 'gr-label-scores');
   }
 
   _handleLabelsChanged() {
-    this._labelsChanged =
+    this.labelsChanged =
       Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0;
   }
 
-  _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
-    return knownLatestState === value;
+  // To decouple account-list and reply dialog
+  getAccountListCopy(list: (AccountInfo | GroupInfo)[]) {
+    return list.slice();
+  }
+
+  handleReviewersChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
+    this.reviewers = e.detail.value.slice();
+    this.reviewersMutated = true;
+  }
+
+  handleCcsChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
+    this.ccs = e.detail.value.slice();
+    this.reviewersMutated = true;
+  }
+
+  handleReviewersConfirmationChanged(
+    e: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+  ) {
+    this.reviewerPendingConfirmation = e.detail.value;
+  }
+
+  handleCcsConfirmationChanged(
+    e: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+  ) {
+    this.ccPendingConfirmation = e.detail.value;
   }
 
   _reload() {
@@ -1296,98 +2013,96 @@
     this.cancel();
   }
 
-  _computeSendButtonLabel(canBeStarted: boolean) {
-    return canBeStarted
+  computeSendButtonLabel() {
+    this.sendButtonLabel = this.canBeStarted
       ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
       : ButtonLabels.SEND;
   }
 
-  _computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
+  computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
     if (commentEditing) {
       return ButtonTooltips.DISABLED_COMMENT_EDITING;
     }
     return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
   }
 
-  _computeSavingLabelClass(savingComments: boolean) {
+  computeSavingLabelClass(savingComments: boolean) {
     return savingComments ? 'saving' : '';
   }
 
-  _computeSendButtonDisabled(
-    canBeStarted?: boolean,
-    draftCommentThreads?: CommentThread[],
-    text?: string,
-    reviewersMutated?: boolean,
-    labelsChanged?: boolean,
-    includeComments?: boolean,
-    disabled?: boolean,
-    commentEditing?: boolean,
-    change?: ChangeInfo,
-    account?: AccountInfo
-  ) {
+  computeSendButtonDisabled() {
     if (
-      canBeStarted === undefined ||
-      draftCommentThreads === undefined ||
-      text === undefined ||
-      reviewersMutated === undefined ||
-      labelsChanged === undefined ||
-      includeComments === undefined ||
-      disabled === undefined ||
-      commentEditing === undefined ||
-      change?.labels === undefined ||
-      account === undefined
+      this.canBeStarted === undefined ||
+      this.draftCommentThreads === undefined ||
+      this.draft === undefined ||
+      this.reviewersMutated === undefined ||
+      this.labelsChanged === undefined ||
+      this.includeComments === undefined ||
+      this.disabled === undefined ||
+      this.commentEditing === undefined ||
+      this.change?.labels === undefined ||
+      this.account === undefined
     ) {
       return undefined;
     }
-    if (commentEditing || disabled) {
+    if (this.commentEditing || this.disabled) {
       return true;
     }
-    if (canBeStarted === true) {
+    if (this.canBeStarted === true) {
       return false;
     }
-    const existingVote = Object.values(change.labels).some(
-      label => isDetailedLabelInfo(label) && getApprovalInfo(label, account)
+    const existingVote = Object.values(this.change.labels).some(
+      label =>
+        isDetailedLabelInfo(label) && getApprovalInfo(label, this.account!)
     );
-    const revotingOrNewVote = labelsChanged || existingVote;
-    const hasDrafts = includeComments && draftCommentThreads.length;
+    const revotingOrNewVote = this.labelsChanged || existingVote;
+    const hasDrafts =
+      this.includeComments && this.draftCommentThreads.length > 0;
     return (
-      !hasDrafts && !text.length && !reviewersMutated && !revotingOrNewVote
+      !hasDrafts &&
+      !this.draft.length &&
+      !this.reviewersMutated &&
+      !revotingOrNewVote
     );
   }
 
-  _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
-    let str = `Patch ${patchNum} is not latest.`;
-    if (labelsChanged) {
+  computePatchSetWarning() {
+    let str = `Patch ${this.patchNum} is not latest.`;
+    if (this.labelsChanged) {
       str += ' Voting may have no effect.';
     }
     return str;
   }
 
   setPluginMessage(message: string) {
-    this._pluginMessage = message;
+    this.pluginMessage = message;
   }
 
-  _sendDisabledChanged() {
+  sendDisabledChanged() {
     this.dispatchEvent(new CustomEvent('send-disabled-changed'));
   }
 
-  _getReviewerSuggestionsProvider(change: ChangeInfo) {
-    const provider = GrReviewerSuggestionsProvider.create(
+  getReviewerSuggestionsProvider(change?: ChangeInfo) {
+    if (!change) return;
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+      ReviewerState.REVIEWER,
+      this.serverConfig,
+      this.isLoggedIn,
+      change._number
     );
-    provider.init();
     return provider;
   }
 
-  _getCcSuggestionsProvider(change: ChangeInfo) {
-    const provider = GrReviewerSuggestionsProvider.create(
+  getCcSuggestionsProvider(change?: ChangeInfo) {
+    if (!change) return;
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
+      ReviewerState.CC,
+      this.serverConfig,
+      this.isLoggedIn,
+      change._number
     );
-    provider.init();
     return provider;
   }
 
@@ -1399,24 +2114,24 @@
     const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED'];
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = (this._account && this._account._account_id) || -1;
+    const selfId = (this.account && this.account._account_id) || -1;
     for (const added of addedSet || []) {
       const addedId = added.user;
       const self = addedId === selfId ? '_SELF' : '';
-      const role = addedId === ownerId ? '_OWNER' : '_REVIEWER';
+      const role = addedId === ownerId ? 'OWNER' : '_REVIEWER';
       actions.push('ADD' + self + role);
     }
     for (const removed of removedSet || []) {
       const removedId = removed.user;
       const self = removedId === selfId ? '_SELF' : '';
-      const role = removedId === ownerId ? '_OWNER' : '_REVIEWER';
+      const role = removedId === ownerId ? 'OWNER' : '_REVIEWER';
       actions.push('REMOVE' + self + role);
     }
     this.reporting.reportInteraction('attention-set-actions', {actions});
   }
 
-  _computeAllReviewers() {
-    return [...this._reviewers];
+  computeAllReviewers() {
+    this.allReviewers = [...this.reviewers];
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
deleted file mode 100644
index 59dcd0e..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ /dev/null
@@ -1,646 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--dialog-background-color);
-      display: block;
-      max-height: 90vh;
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .container {
-      opacity: 0.5;
-    }
-    section {
-      border-top: 1px solid var(--border-color);
-      flex-shrink: 0;
-      padding: var(--spacing-m) var(--spacing-xl);
-      width: 100%;
-    }
-    section.labelsContainer {
-      /* We want the :hover highlight to extend to the border of the dialog. */
-      padding: var(--spacing-m) 0;
-    }
-    .stickyBottom {
-      background-color: var(--dialog-background-color);
-      box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
-      margin-top: var(--spacing-s);
-      bottom: 0;
-      position: sticky;
-      /* @see Issue 8602 */
-      z-index: 1;
-    }
-    .stickyBottom.newReplyDialog {
-      margin-top: unset;
-    }
-    .actions {
-      display: flex;
-      justify-content: space-between;
-    }
-    .actions .right gr-button {
-      margin-left: var(--spacing-l);
-    }
-    .peopleContainer,
-    .labelsContainer {
-      flex-shrink: 0;
-    }
-    .peopleContainer {
-      border-top: none;
-      display: table;
-    }
-    .peopleList {
-      display: flex;
-    }
-    .peopleListLabel {
-      color: var(--deemphasized-text-color);
-      margin-top: var(--spacing-xs);
-      min-width: 6em;
-      padding-right: var(--spacing-m);
-    }
-    gr-account-list {
-      display: flex;
-      flex-wrap: wrap;
-      flex: 1;
-    }
-    #reviewerConfirmationOverlay {
-      padding: var(--spacing-l);
-      text-align: center;
-    }
-    .reviewerConfirmationButtons {
-      margin-top: var(--spacing-l);
-    }
-    .groupName {
-      font-weight: var(--font-weight-bold);
-    }
-    .groupSize {
-      font-style: italic;
-    }
-    .textareaContainer {
-      min-height: 12em;
-      position: relative;
-    }
-    .newReplyDialog.textareaContainer {
-      min-height: unset;
-    }
-    textareaContainer,
-    #textarea,
-    gr-endpoint-decorator[name='reply-text'] {
-      display: flex;
-      width: 100%;
-    }
-    .newReplyDialog .textareaContainer,
-    #textarea,
-    gr-endpoint-decorator[name='reply-text'] {
-      display: block;
-      width: unset;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      line-height: calc(var(--font-size-code) + var(--spacing-s));
-      font-weight: var(--font-weight-normal);
-    }
-    .newReplyDialog#textarea {
-      padding: var(--spacing-m);
-    }
-    gr-endpoint-decorator[name='reply-text'] {
-      flex-direction: column;
-    }
-    #textarea {
-      flex: 1;
-    }
-    .previewContainer {
-      border-top: none;
-    }
-    .previewContainer gr-formatted-text {
-      background: var(--table-header-background-color);
-      padding: var(--spacing-l);
-    }
-    #checkingStatusLabel,
-    #notLatestLabel {
-      margin-left: var(--spacing-l);
-    }
-    #checkingStatusLabel {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-    }
-    #notLatestLabel,
-    #savingLabel {
-      color: var(--error-text-color);
-    }
-    #savingLabel {
-      display: none;
-    }
-    #savingLabel.saving {
-      display: inline;
-    }
-    #pluginMessage {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-l);
-      margin-bottom: var(--spacing-m);
-    }
-    #pluginMessage:empty {
-      display: none;
-    }
-    .preview-formatting {
-      margin-left: var(--spacing-m);
-    }
-    .attention-icon {
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 3px;
-      --iron-icon-height: 24px;
-      --iron-icon-width: 24px;
-    }
-    .attention .edit-attention-button {
-      vertical-align: top;
-      --gr-button-padding: 0px 4px;
-    }
-    .attention .edit-attention-button iron-icon {
-      color: inherit;
-    }
-    .attention a,
-    .attention-detail a {
-      text-decoration: none;
-    }
-    .attentionSummary {
-      display: flex;
-      justify-content: space-between;
-    }
-    .attentionSummary {
-      /* The account label for selection is misbehaving currently: It consumes
-         26px height instead of 20px, which is the default line-height and thus
-         the max that can be nicely fit into an inline layout flow. We
-         acknowledge that using a fixed 26px value here is a hack and not a
-         great solution. */
-      line-height: 26px;
-    }
-    .attentionSummary gr-account-label,
-    .attention-detail gr-account-label {
-      --account-max-length: 120px;
-      display: inline-block;
-      padding: var(--spacing-xs) var(--spacing-m);
-      user-select: none;
-      --label-border-radius: 8px;
-    }
-    .attentionSummary gr-account-label {
-      margin: 0 var(--spacing-xs);
-      line-height: var(--line-height-normal);
-      vertical-align: top;
-    }
-    .attention-detail .peopleListValues {
-      line-height: calc(var(--line-height-normal) + 10px);
-    }
-    .attention-detail gr-account-label {
-      line-height: var(--line-height-normal);
-    }
-    .attentionSummary gr-account-label:focus,
-    .attention-detail gr-account-label:focus {
-      outline: none;
-    }
-    .attentionSummary gr-account-label:hover,
-    .attention-detail gr-account-label:hover {
-      box-shadow: var(--elevation-level-1);
-      cursor: pointer;
-    }
-    .attention-detail .attentionDetailsTitle {
-      display: flex;
-      justify-content: space-between;
-    }
-    .attention-detail .selectUsers {
-      color: var(--deemphasized-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    .attentionTip {
-      padding: var(--spacing-m);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin-top: var(--spacing-m);
-      background-color: var(--assignee-highlight-color);
-    }
-    .attentionTip div iron-icon {
-      margin-right: var(--spacing-s);
-    }
-    .patchsetLevelContainer {
-      width: 80ch;
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-2);
-    }
-    .patchsetLevelContainer.resolved{
-      background-color: var(--comment-background-color);
-    }
-    .patchsetLevelContainer.unresolved{
-      background-color: var(--unresolved-comment-background-color);
-    }
-    .labelContainer {
-      padding-left: var(--spacing-m);
-      padding-bottom: var(--spacing-m);
-    }
-
-  </style>
-  <div tabindex="-1">
-    <section class="peopleContainer">
-      <gr-endpoint-decorator name="reply-reviewers">
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <gr-endpoint-param name="reviewers" value="[[_allReviewers]]">
-        </gr-endpoint-param>
-        <div class="peopleList">
-          <div class="peopleListLabel">Reviewers</div>
-          <gr-account-list
-            id="reviewers"
-            accounts="{{_reviewers}}"
-            removable-values="[[change.removable_reviewers]]"
-            filter="[[filterReviewerSuggestion]]"
-            pending-confirmation="{{_reviewerPendingConfirmation}}"
-            placeholder="Add reviewer..."
-            on-account-text-changed="_handleAccountTextEntry"
-            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
-          >
-          </gr-account-list>
-          <gr-endpoint-slot name="right"></gr-endpoint-slot>
-        </div>
-        <gr-endpoint-slot name="below"></gr-endpoint-slot>
-      </gr-endpoint-decorator>
-      <div class="peopleList">
-        <div class="peopleListLabel">CC</div>
-        <gr-account-list
-          id="ccs"
-          accounts="{{_ccs}}"
-          filter="[[filterCCSuggestion]]"
-          pending-confirmation="{{_ccPendingConfirmation}}"
-          allow-any-input=""
-          placeholder="Add CC..."
-          on-account-text-changed="_handleAccountTextEntry"
-          suggestions-provider="[[_getCcSuggestionsProvider(change)]]"
-        >
-        </gr-account-list>
-      </div>
-      <gr-overlay
-        id="reviewerConfirmationOverlay"
-        on-iron-overlay-canceled="_cancelPendingReviewer"
-      >
-        <div class="reviewerConfirmation">
-          Group
-          <span class="groupName">
-            [[_pendingConfirmationDetails.group.name]]
-          </span>
-          has
-          <span class="groupSize"> [[_pendingConfirmationDetails.count]] </span>
-          members.
-          <br />
-          Are you sure you want to add them all?
-        </div>
-        <div class="reviewerConfirmationButtons">
-          <gr-button on-click="_confirmPendingReviewer">Yes</gr-button>
-          <gr-button on-click="_cancelPendingReviewer">No</gr-button>
-        </div>
-      </gr-overlay>
-    </section>
-
-    <section class="labelsContainer">
-      <gr-endpoint-decorator name="reply-label-scores">
-        <gr-label-scores
-          id="labelScores"
-          account="[[_account]]"
-          change="[[change]]"
-          on-labels-changed="_handleLabelsChanged"
-          permitted-labels="[[permittedLabels]]"
-        ></gr-label-scores>
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-      <div id="pluginMessage">[[_pluginMessage]]</div>
-    </section>
-    <section class="newReplyDialog textareaContainer">
-      <div class$="patchsetLevelContainer [[getUnresolvedPatchsetLevelClass(_isResolvedPatchsetLevelComment)]]">
-        <gr-endpoint-decorator name="reply-text">
-          <gr-textarea
-            id="textarea"
-            class="message newReplyDialog"
-            autocomplete="on"
-            placeholder="[[_messagePlaceholder]]"
-            monospace="true"
-            disabled="{{disabled}}"
-            rows="4"
-            text="{{draft}}"
-            on-bind-value-changed="_handleHeightChanged"
-          >
-          </gr-textarea>
-          <gr-endpoint-param name="change" value="[[change]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-        <div class="labelContainer">
-          <label>
-            <input
-              id="resolvedPatchsetLevelCommentCheckbox"
-              type="checkbox"
-              checked="{{_isResolvedPatchsetLevelComment::change}}"
-            />
-            Resolved
-          </label>
-          <label class="preview-formatting">
-            <input type="checkbox" checked="{{_previewFormatting::change}}" />
-            Preview formatting
-          </label>
-        </div>
-      </div>
-    </section>
-    <template is="dom-if" if="[[_previewFormatting]]">
-      <section class="previewContainer">
-        <gr-formatted-text
-          content="[[draft]]"
-          config="[[projectConfig.commentlinks]]"
-        ></gr-formatted-text>
-    </template>
-    </section>
-
-    <section
-      class="draftsContainer"
-      hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
-    >
-      <div class="includeComments">
-        <input
-          type="checkbox"
-          id="includeComments"
-          checked="{{_includeComments::change}}"
-        />
-        <label for="includeComments"
-          >Publish [[_computeDraftsTitle(draftCommentThreads)]]</label
-        >
-      </div>
-      <gr-thread-list
-        id="commentList"
-        hidden$="[[!_includeComments]]"
-        threads="[[draftCommentThreads]]"
-        hide-dropdown=""
-      >
-      </gr-thread-list>
-      <span
-        id="savingLabel"
-        class$="[[_computeSavingLabelClass(_savingComments)]]"
-      >
-        Saving comments...
-      </span>
-    </section>
-    <div class="stickyBottom newReplyDialog">
-      <gr-endpoint-decorator name="reply-bottom">
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <section
-          hidden$="[[!_showAttentionSummary(_attentionExpanded)]]"
-          class="attention"
-        >
-          <div class="attentionSummary">
-            <div>
-              <template
-                is="dom-if"
-                if="[[_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
-              >
-                <span
-                  >[[_computeDoNotUpdateMessage(_currentAttentionSet,
-                  _newAttentionSet, _sendDisabled)]]</span
-                >
-              </template>
-              <template
-                is="dom-if"
-                if="[[!_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
-              >
-                <span>Bring to attention of</span>
-                <template
-                  is="dom-repeat"
-                  items="[[_computeNewAttentionAccounts(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
-                  as="account"
-                >
-                  <gr-account-label
-                    account="[[account]]"
-                    force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                    selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                    hideHovercard
-                    selectionChipStyle
-                    on-click="_handleAttentionClick"
-                  ></gr-account-label>
-                </template>
-              </template>
-              <gr-tooltip-content
-                has-tooltip
-                title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
-              >
-                <gr-button
-                  class="edit-attention-button"
-                  on-click="_handleAttentionModify"
-                  disabled="[[_sendDisabled]]"
-                  link=""
-                  position-below=""
-                  data-label="Edit"
-                  data-action-type="change"
-                  data-action-key="edit"
-                  role="button"
-                  tabindex="0"
-                >
-                  <iron-icon icon="gr-icons:edit"></iron-icon>
-                  Modify
-                </gr-button>
-              </gr-tooltip-content>
-            </div>
-            <div>
-              <a
-                href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-                target="_blank"
-              >
-                <iron-icon
-                  icon="gr-icons:help-outline"
-                  title="read documentation"
-                ></iron-icon>
-              </a>
-            </div>
-          </div>
-        </section>
-        <section
-          hidden$="[[!_showAttentionDetails(_attentionExpanded)]]"
-          class="attention-detail"
-        >
-          <div class="attentionDetailsTitle">
-            <div>
-              <span>Modify attention to</span>
-            </div>
-            <div></div>
-            <div>
-              <a
-                href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-                target="_blank"
-              >
-                <iron-icon
-                  icon="gr-icons:help-outline"
-                  title="read documentation"
-                ></iron-icon>
-              </a>
-            </div>
-          </div>
-          <div class="selectUsers">
-            <span
-              >Select chips to set who will be in the attention set after sending
-              this reply</span
-            >
-          </div>
-          <div class="peopleList">
-            <div class="peopleListLabel">Owner</div>
-            <div class="peopleListValues">
-              <gr-account-label
-                account="[[_owner]]"
-                force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-                selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-                hideHovercard
-                selectionChipStyle
-                on-click="_handleAttentionClick"
-              >
-              </gr-account-label>
-            </div>
-          </div>
-          <template is="dom-if" if="[[_uploader]]">
-            <div class="peopleList">
-              <div class="peopleListLabel">Uploader</div>
-              <div class="peopleListValues">
-                <gr-account-label
-                  account="[[_uploader]]"
-                  force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                  selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                  hideHovercard
-                  selectionChipStyle
-                  on-click="_handleAttentionClick"
-                >
-                </gr-account-label>
-              </div>
-            </div>
-          </template>
-          <div class="peopleList">
-            <div class="peopleListLabel">Reviewers</div>
-            <div class="peopleListValues">
-              <template
-                is="dom-repeat"
-                items="[[_removeServiceUsers(_reviewers, _newAttentionSet)]]"
-                as="account"
-              >
-                <gr-account-label
-                  account="[[account]]"
-                  force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hideHovercard
-                  selectionChipStyle
-                  on-click="_handleAttentionClick"
-                >
-                </gr-account-label>
-              </template>
-            </div>
-          </div>
-          <template is="dom-if" if="[[_attentionCcsCount]]">
-            <div class="peopleList">
-              <div class="peopleListLabel">CC</div>
-              <div class="peopleListValues">
-                <template
-                  is="dom-repeat"
-                  items="[[_removeServiceUsers(_ccs, _newAttentionSet)]]"
-                  as="account"
-                >
-                  <gr-account-label
-                    account="[[account]]"
-                    force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                    selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                    hideHovercard
-                    selectionChipStyle
-                    on-click="_handleAttentionClick"
-                  >
-                  </gr-account-label>
-                </template>
-              </div>
-            </div>
-          </template>
-          <template
-            is="dom-if"
-            if="[[_computeShowAttentionTip(_account, _owner, _currentAttentionSet, _newAttentionSet)]]"
-          >
-            <div class="attentionTip">
-              <iron-icon
-                class="pointer"
-                icon="gr-icons:lightbulb-outline"
-              ></iron-icon>
-              Be mindful of requiring attention from too many users.
-            </div>
-          </template>
-        </section>
-        <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
-        <section class="actions">
-          <div class="left">
-            <span
-              id="checkingStatusLabel"
-              hidden$="[[!_isState(knownLatestState, 'checking')]]"
-            >
-              Checking whether patch [[patchNum]] is latest...
-            </span>
-            <span
-              id="notLatestLabel"
-              hidden$="[[!_isState(knownLatestState, 'not-latest')]]"
-            >
-              [[_computePatchSetWarning(patchNum, _labelsChanged)]]
-              <gr-button link="" on-click="_reload">Reload</gr-button>
-            </span>
-          </div>
-          <div class="right">
-            <gr-button
-              link=""
-              id="cancelButton"
-              class="action cancel"
-              on-click="_cancelTapHandler"
-              >Cancel</gr-button
-            >
-            <template is="dom-if" if="[[canBeStarted]]">
-              <!-- Use 'Send' here as the change may only about reviewers / ccs
-                  and when this button is visible, the next button will always
-                  be 'Start review' -->
-              <gr-tooltip-content
-                has-tooltip=""
-                title$="[[_saveTooltip]]"
-              >
-                <gr-button
-                  link=""
-                  disabled="[[_isState(knownLatestState, 'not-latest')]]"
-                  class="action save"
-                  on-click="_saveClickHandler"
-                  >Send As WIP</gr-button
-                >
-              </gr-tooltip-content>
-            </template>
-            <gr-tooltip-content
-              has-tooltip=""
-              title$="[[_computeSendButtonTooltip(canBeStarted, _commentEditing)]]"
-            >
-              <gr-button
-                id="sendButton"
-                primary=""
-                disabled="[[_sendDisabled]]"
-                class="action send"
-                on-click="_sendTapHandler"
-                >[[_sendButtonLabel]]
-              </gr-button>
-            </gr-tooltip-content>
-          </div>
-        </section>
-      </gr-endpoint-decorator>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 14ad4ad..c800c87 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -61,17 +61,14 @@
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {
-  AccountInfoInput,
-  GrAccountList,
-} from '../../shared/gr-account-list/gr-account-list';
+import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-
-const basicFixture = fixtureFromElement('gr-reply-dialog');
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {accountKey} from '../../../utils/account-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -102,12 +99,6 @@
   let setDraftCommentStub: sinon.SinonStub;
   let eraseDraftCommentStub: sinon.SinonStub;
 
-  const emptyAccountInfoInputChanges =
-    [] as unknown as PolymerDeepPropertyChange<
-      AccountInfoInput[],
-      AccountInfoInput[]
-    >;
-
   let lastId = 1;
   const makeAccount = function () {
     return {_account_id: lastId++ as AccountId};
@@ -123,12 +114,15 @@
     stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
     stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrReplyDialog>(html`
+      <gr-reply-dialog></gr-reply-dialog>
+    `);
+
     element.change = {
       ...createChange(),
       _number: changeNum,
       owner: {
-        _account_id: 999 as AccountId as AccountId,
+        _account_id: 999 as AccountId,
         display_name: 'Kermit',
       },
       labels: {
@@ -142,8 +136,8 @@
         },
         'Code-Review': {
           values: {
-            '-2': 'Do not submit',
-            '-1': "I would prefer that you didn't submit this",
+            '-2': 'This shall not be submitted',
+            '-1': 'I would prefer this is not submitted as is',
             ' 0': 'No score',
             '+1': 'Looks good to me, but someone else must approve',
             '+2': 'Looks good to me, approved',
@@ -162,17 +156,13 @@
     setDraftCommentStub = stubStorage('setDraftComment');
     eraseDraftCommentStub = stubStorage('eraseDraftComment');
 
-    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
-    //     .returns(Promise.resolve({isLatest: true}));
-
-    // Allow the elements created by dom-repeat to be stamped.
-    await flush();
+    await element.updateComplete;
   });
 
   function stubSaveReview(
     jsonResponseProducer: (input: ReviewInput) => ReviewResult | void
   ) {
-    return sinon.stub(element, '_saveReview').callsFake(
+    return sinon.stub(element, 'saveReview').callsFake(
       review =>
         new Promise((resolve, reject) => {
           try {
@@ -206,15 +196,18 @@
   test('default to publishing draft comments with reply', async () => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    await flush();
+    await element.updateComplete;
     element.draft = 'I wholeheartedly disapprove';
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+
+    element.includeComments = true;
     const saveReviewPromise = interceptSaveReview();
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    await flush();
+    await element.updateComplete;
     tap(queryAndAssert(element, '.send'));
-    await flush();
+    await element.updateComplete;
 
     const review = await saveReviewPromise;
     assert.deepEqual(review, {
@@ -232,23 +225,25 @@
         ],
       },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
     assert.isFalse(
-      (queryAndAssert(element, '#commentList') as GrThreadList).hidden
+      queryAndAssert<GrThreadList>(element, '#commentList').hidden
     );
   });
 
   test('modified attention set', async () => {
-    await flush();
-    element._account = {_account_id: 123 as AccountId};
-    element._newAttentionSet = new Set([314 as AccountId]);
+    await element.updateComplete;
+    element.account = {_account_id: 123 as AccountId};
+    element.newAttentionSet = new Set([314 as AccountId]);
     const saveReviewPromise = interceptSaveReview();
     const modifyButton = queryAndAssert(element, '.edit-attention-button');
     tap(modifyButton);
-    await flush();
+    await element.updateComplete;
 
     tap(queryAndAssert(element, '.send'));
     const review = await saveReviewPromise;
@@ -269,13 +264,13 @@
   });
 
   test('modified attention set by anonymous', async () => {
-    await flush();
-    element._account = {};
-    element._newAttentionSet = new Set([314 as AccountId]);
+    await element.updateComplete;
+    element.account = {};
+    element.newAttentionSet = new Set([314 as AccountId]);
     const saveReviewPromise = interceptSaveReview();
     const modifyButton = queryAndAssert(element, '.edit-attention-button');
     tap(modifyButton);
-    await flush();
+    await element.updateComplete;
 
     tap(queryAndAssert(element, '.send'));
     const review = await saveReviewPromise;
@@ -293,11 +288,12 @@
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
-    element._newAttentionSet = new Set();
-    await flush();
+    element.newAttentionSet = new Set();
+    await element.updateComplete;
   });
 
-  function checkComputeAttention(
+  async function checkComputeAttention(
+    element: GrReplyDialog,
     status: ChangeStatus,
     userId?: AccountId,
     reviewerIds?: AccountId[],
@@ -309,12 +305,11 @@
     hasDraft = true,
     includeComments = true
   ) {
-    const user = {_account_id: userId};
-    const reviewers = {
-      base: reviewerIds?.map(id => {
+    element.account = {_account_id: userId};
+    element.reviewers =
+      reviewerIds?.map(id => {
         return {_account_id: id};
-      }),
-    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+      }) ?? [];
     let draftThreads: CommentThread[] = [];
     if (hasDraft) {
       draftThreads = [
@@ -333,6 +328,9 @@
       ...createChange(),
       owner: {_account_id: ownerId},
       status,
+      reviewers: {
+        [ReviewerState.REVIEWER]: element.reviewers,
+      },
     };
     attSetIds?.forEach(id => {
       if (!change.attention_set) change.attention_set = {};
@@ -348,25 +346,19 @@
       };
     }
     element.change = change;
-    element._reviewers = reviewers.base!;
+    element.ccs = [];
+    element.draftCommentThreads = draftThreads;
+    element.includeComments = includeComments;
 
-    flush();
-    const hasDrafts = draftThreads.length > 0;
-    element._computeNewAttention(
-      user,
-      reviewers!,
-      emptyAccountInfoInputChanges,
-      change,
-      draftThreads,
-      includeComments,
-      undefined,
-      hasDrafts
-    );
-    assert.sameMembers([...element._newAttentionSet], expectedIds!);
+    await element.updateComplete;
+
+    element.computeNewAttention();
+    assert.sameMembers([...element.newAttentionSet], expectedIds!);
   }
 
-  test('computeNewAttention NEW', () => {
-    checkComputeAttention(
+  test('computeNewAttention NEW', async () => {
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -375,7 +367,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -384,7 +377,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -393,7 +387,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -402,7 +397,8 @@
       [],
       [22 as AccountId, 999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -411,7 +407,8 @@
       [22 as AccountId],
       [22 as AccountId, 999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -421,7 +418,8 @@
       [22 as AccountId, 33 as AccountId, 999 as AccountId]
     );
     // If the owner replies, then do not add them.
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -430,7 +428,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -439,7 +438,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -449,7 +449,8 @@
       []
     );
 
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -458,7 +459,8 @@
       [22 as AccountId],
       [22 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -467,7 +469,8 @@
       [22 as AccountId],
       [22 as AccountId, 33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -476,7 +479,8 @@
       [22 as AccountId],
       [22 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -485,7 +489,8 @@
       [22 as AccountId, 33 as AccountId],
       [22 as AccountId, 33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -495,7 +500,8 @@
       [22 as AccountId, 33 as AccountId]
     );
     // with uploader
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -505,7 +511,8 @@
       [2 as AccountId],
       2 as AccountId
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -515,7 +522,8 @@
       [2 as AccountId],
       2 as AccountId
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -527,8 +535,9 @@
     );
   });
 
-  test('computeNewAttention MERGED', () => {
-    checkComputeAttention(
+  test('computeNewAttention MERGED', async () => {
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       undefined,
       [],
@@ -539,7 +548,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -550,7 +560,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -561,7 +572,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -573,7 +585,8 @@
       true,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -584,7 +597,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -595,7 +609,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -606,7 +621,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -615,7 +631,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -624,7 +641,8 @@
       [22 as AccountId],
       [33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -633,7 +651,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -644,7 +663,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -653,7 +673,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -664,7 +685,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -673,7 +695,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -682,7 +705,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -691,7 +715,8 @@
       [22 as AccountId],
       [33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -700,7 +725,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -709,7 +735,8 @@
       [22 as AccountId, 33 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -720,118 +747,86 @@
     );
   });
 
-  test('computeNewAttention when adding reviewers', () => {
-    const user = {_account_id: 1 as AccountId};
-    const reviewers = {
-      base: [
-        {_account_id: 1 as AccountId, _pendingAdd: true},
-        {_account_id: 2 as AccountId, _pendingAdd: true},
-      ],
-    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
-    const change = {
+  test('computeNewAttention when adding reviewers', async () => {
+    element.account = {_account_id: 1 as AccountId};
+    element.change = {
       ...createChange(),
       owner: {_account_id: 5 as AccountId},
       status: ChangeStatus.NEW,
       attention_set: {},
     };
-    element.change = change;
-    element._reviewers = reviewers.base;
-    flush();
+    // let rebuildReviewers triggered by change update finish running
+    await element.updateComplete;
 
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      true
-    );
-    assert.sameMembers([...element._newAttentionSet], [1, 2]);
+    element.reviewers = [
+      {_account_id: 1 as AccountId, _pendingAdd: true},
+      {_account_id: 2 as AccountId, _pendingAdd: true},
+    ];
+    element.ccs = [];
+    element.draftCommentThreads = [];
+    element.includeComments = true;
+    element.canBeStarted = true;
+    await element.updateComplete;
+    assert.sameMembers([...element.newAttentionSet], [1, 2]);
 
     // If the user votes on the change, then they should not be added to the
     // attention set, even if they have just added themselves as reviewer.
     // But voting should also add the owner (5).
-    const labelsChanged = true;
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      true,
-      labelsChanged
-    );
-    assert.sameMembers([...element._newAttentionSet], [2, 5]);
+    element.labelsChanged = true;
+    await element.updateComplete;
+    assert.sameMembers([...element.newAttentionSet], [2, 5]);
   });
 
-  test('computeNewAttention when sending wip change for review', () => {
-    const reviewers = {
-      base: [{...createAccountWithId(2)}, {...createAccountWithId(3)}],
-    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
-    const change = {
+  test('computeNewAttention when sending wip change for review', async () => {
+    element.change = {
       ...createChange(),
       owner: {_account_id: 1 as AccountId},
       status: ChangeStatus.NEW,
       attention_set: {},
     };
-    element.change = change;
-    element._reviewers = reviewers.base;
-    flush();
+    // let rebuildReviewers triggered by change update finish running
+    await element.updateComplete;
+
+    element.reviewers = [
+      {...createAccountWithId(2)},
+      {...createAccountWithId(3)},
+    ];
+
+    element.ccs = [];
+    element.draftCommentThreads = [];
+    element.includeComments = false;
+    element.account = {_account_id: 1 as AccountId};
+
+    await element.updateComplete;
 
     // For an active change there is no reason to add anyone to the set.
-    let user = {_account_id: 1 as AccountId};
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      false
-    );
-    assert.sameMembers([...element._newAttentionSet], []);
+    element.computeNewAttention();
+    assert.sameMembers([...element.newAttentionSet], []);
 
     // If the change is "work in progress" and the owner sends a reply, then
     // add all reviewers.
     element.canBeStarted = true;
-    flush();
-    user = {_account_id: 1 as AccountId};
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      false
-    );
-    assert.sameMembers([...element._newAttentionSet], [2, 3]);
+    element.computeNewAttention();
+    await element.updateComplete;
+    assert.sameMembers([...element.newAttentionSet], [2, 3]);
 
     // ... but not when someone else replies.
-    user = {_account_id: 4 as AccountId};
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      false
-    );
-    assert.sameMembers([...element._newAttentionSet], []);
+    element.account = {_account_id: 4 as AccountId};
+    element.computeNewAttention();
+    assert.sameMembers([...element.newAttentionSet], []);
   });
 
   test('computeNewAttentionAccounts', () => {
-    element._reviewers = [
+    element.reviewers = [
       {_account_id: 123 as AccountId, display_name: 'Ernie'},
       {_account_id: 321 as AccountId, display_name: 'Bert'},
     ];
-    element._ccs = [{_account_id: 7 as AccountId, display_name: 'Elmo'}];
-    const compute = (currentAtt: AccountId[], newAtt: AccountId[]) =>
-      element
-        ._computeNewAttentionAccounts(
-          undefined,
-          new Set(currentAtt),
-          new Set(newAtt)
-        )
-        .map(a => a!._account_id);
+    element.ccs = [{_account_id: 7 as AccountId, display_name: 'Elmo'}];
+    const compute = (currentAtt: AccountId[], newAtt: AccountId[]) => {
+      element.currentAttentionSet = new Set(currentAtt);
+      element.newAttentionSet = new Set(newAtt);
+      return element.computeNewAttentionAccounts().map(a => a?._account_id);
+    };
 
     assert.sameMembers(compute([], []), []);
     assert.sameMembers(compute([], [999 as AccountId]), [999 as AccountId]);
@@ -850,7 +845,7 @@
     );
   });
 
-  test('_computeCommentAccounts', () => {
+  test('computeCommentAccounts', () => {
     element.change = {
       ...createChange(),
       labels: {
@@ -861,8 +856,8 @@
             {_account_id: 3 as AccountId, value: 2},
           ],
           values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didnt submit this',
+            '-2': 'This shall not be submitted',
+            '-1': 'I would prefer this is not submitted as is',
             ' 0': 'No score',
             '+1': 'Looks good to me, but someone else must approve',
             '+2': 'Looks good to me, approved',
@@ -906,7 +901,7 @@
         ]),
       },
     ];
-    const actualAccounts = [...element._computeCommentAccounts(threads)];
+    const actualAccounts = [...element.computeCommentAccounts(threads)];
     // Account 3 is not included, because the comment is resolved *and* they
     // have given the highest possible vote on the Code-Review label.
     assert.sameMembers(actualAccounts, [1, 2, 4]);
@@ -921,13 +916,15 @@
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    await flush();
+    await element.updateComplete;
     element.draft = 'I wholeheartedly disapprove';
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+
     const saveReviewPromise = interceptSaveReview();
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    await flush();
+    await element.updateComplete;
     tap(queryAndAssert(element, '.send'));
 
     const review = await saveReviewPromise;
@@ -946,7 +943,9 @@
         ],
       },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
@@ -954,6 +953,8 @@
 
   test('label picker', async () => {
     element.draft = 'I wholeheartedly disapprove';
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+
     const saveReviewPromise = interceptSaveReview();
 
     sinon.stub(element.getLabelScores(), 'getLabelValues').callsFake(() => {
@@ -965,12 +966,12 @@
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    await flush();
+    await element.updateComplete;
     tap(queryAndAssert(element, '.send'));
     assert.isTrue(element.disabled);
 
     const review = await saveReviewPromise;
-    await flush();
+    await element.updateComplete;
     assert.isFalse(
       element.disabled,
       'Element should be enabled when done sending reply.'
@@ -991,29 +992,35 @@
         ],
       },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {user: 999, reason: '<GERRIT_ACCOUNT_1> replied on the change'},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
   });
 
   test('keep draft comments with reply', async () => {
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+    await element.updateComplete;
+
     tap(queryAndAssert(element, '#includeComments'));
-    assert.equal(element._includeComments, false);
+    assert.equal(element.includeComments, false);
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    await flush();
+    await element.updateComplete;
     element.draft = 'I wholeheartedly disapprove';
+
     const saveReviewPromise = interceptSaveReview();
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    await flush();
+    await element.updateComplete;
     tap(queryAndAssert(element, '.send'));
 
     const review = await saveReviewPromise;
-    await flush();
+    await element.updateComplete;
     assert.deepEqual(review, {
       drafts: 'KEEP',
       labels: {
@@ -1029,42 +1036,43 @@
         ],
       },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
   });
 
   test('getlabelValue returns value', async () => {
-    await flush();
-    const el = queryAndAssert(
+    const el = queryAndAssert<GrLabelScoreRow>(
       queryAndAssert(element, 'gr-label-scores'),
       'gr-label-score-row[name="Verified"]'
-    ) as GrLabelScoreRow;
+    );
     el.setSelectedValue('-1');
     assert.equal('-1', element.getLabelValue('Verified'));
   });
 
   test('getlabelValue when no score is selected', async () => {
-    await flush();
-    const el = queryAndAssert(
+    const el = queryAndAssert<GrLabelScoreRow>(
       queryAndAssert(element, 'gr-label-scores'),
       'gr-label-score-row[name="Code-Review"]'
-    ) as GrLabelScoreRow;
+    );
     el.setSelectedValue('-1');
     assert.strictEqual(element.getLabelValue('Verified'), ' 0');
   });
 
   test('setlabelValue', async () => {
-    element._account = {_account_id: 1 as AccountId};
-    await flush();
+    element.account = {_account_id: 1 as AccountId};
+    await element.updateComplete;
     const label = 'Verified';
     const value = '+1';
     element.setLabelValue(label, value);
-    await flush();
+    await element.updateComplete;
 
-    const labels = (
-      queryAndAssert(element, '#labelScores') as GrLabelScores
+    const labels = queryAndAssert<GrLabelScores>(
+      element,
+      '#labelScores'
     ).getLabelValues();
     assert.deepEqual(labels, {
       'Code-Review': 0,
@@ -1121,9 +1129,9 @@
       '.reviewerConfirmationButtons gr-button:last-child'
     );
 
-    element._ccPendingConfirmation = null;
-    element._reviewerPendingConfirmation = null;
-    flush();
+    element.ccPendingConfirmation = null;
+    element.reviewerPendingConfirmation = null;
+    await element.updateComplete;
     assert.isFalse(
       isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
     );
@@ -1135,27 +1143,29 @@
       name: 'name' as GroupName,
     };
     if (cc) {
-      element._ccPendingConfirmation = {
+      element.ccPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     } else {
-      element._reviewerPendingConfirmation = {
+      element.reviewerPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     }
-    flush();
+    await element.updateComplete;
 
     if (cc) {
       assert.deepEqual(
-        element._ccPendingConfirmation,
-        element._pendingConfirmationDetails
+        element.ccPendingConfirmation,
+        element.pendingConfirmationDetails
       );
     } else {
       assert.deepEqual(
-        element._reviewerPendingConfirmation,
-        element._pendingConfirmationDetails
+        element.reviewerPendingConfirmation,
+        element.pendingConfirmationDetails
       );
     }
 
@@ -1166,8 +1176,9 @@
     observer = overlayObserver('closed');
     const expected = 'Group name has 10 members';
     assert.notEqual(
-      (
-        queryAndAssert(element, 'reviewerConfirmationOverlay') as GrOverlay
+      queryAndAssert<HTMLElement>(
+        element,
+        'reviewerConfirmationOverlay'
       ).innerText.indexOf(expected),
       -1
     );
@@ -1179,35 +1190,36 @@
     );
 
     // We should be focused on account entry input.
+    const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
     assert.isTrue(
       isFocusInsideElement(
-        (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$.input
-          .$.input
+        queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
       )
     );
 
     // No reviewer/CC should have been added.
     assert.equal(
-      (queryAndAssert(element, '#ccs') as GrAccountList).additions().length,
+      queryAndAssert<GrAccountList>(element, '#ccs').additions().length,
       0
     );
     assert.equal(
-      (queryAndAssert(element, '#reviewers') as GrAccountList).additions()
-        .length,
+      queryAndAssert<GrAccountList>(element, '#reviewers').additions().length,
       0
     );
 
     // Reopen confirmation dialog.
     observer = overlayObserver('opened');
     if (cc) {
-      element._ccPendingConfirmation = {
+      element.ccPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     } else {
-      element._reviewerPendingConfirmation = {
+      element.reviewerPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     }
 
@@ -1223,8 +1235,8 @@
       isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
     );
     const additions = cc
-      ? (queryAndAssert(element, '#ccs') as GrAccountList).additions()
-      : (queryAndAssert(element, '#reviewers') as GrAccountList).additions();
+      ? queryAndAssert<GrAccountList>(element, '#ccs').additions()
+      : queryAndAssert<GrAccountList>(element, '#reviewers').additions();
     assert.deepEqual(additions, [
       {
         group: {
@@ -1239,17 +1251,20 @@
 
     // We should be focused on account entry input.
     if (cc) {
+      const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
       assert.isTrue(
         isFocusInsideElement(
-          (queryAndAssert(element, '#ccs') as GrAccountList).$.entry.$.input.$
-            .input
+          queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
         )
       );
     } else {
+      const reviewersEntry = queryAndAssert<GrAccountList>(
+        element,
+        '#reviewers'
+      );
       assert.isTrue(
         isFocusInsideElement(
-          (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$
-            .input.$.input
+          queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
         )
       );
     }
@@ -1263,26 +1278,23 @@
     testConfirmationDialog(false);
   });
 
-  test('_getStorageLocation', () => {
-    const actual = element._getStorageLocation();
+  test('getStorageLocation', () => {
+    const actual = element.getStorageLocation();
     assert.equal(actual.changeNum, changeNum);
     assert.equal(actual.patchNum, '@change');
     assert.equal(actual.path, '@change');
   });
 
-  test('_reviewersMutated when account-text-change is fired from ccs', () => {
-    flush();
-    assert.isFalse(element._reviewersMutated);
-    assert.isTrue(
-      (queryAndAssert(element, '#ccs') as GrAccountList).allowAnyInput
-    );
+  test('reviewersMutated when account-text-change is fired from ccs', () => {
+    assert.isFalse(element.reviewersMutated);
+    assert.isTrue(queryAndAssert<GrAccountList>(element, '#ccs').allowAnyInput);
     assert.isFalse(
-      (queryAndAssert(element, '#reviewers') as GrAccountList).allowAnyInput
+      queryAndAssert<GrAccountList>(element, '#reviewers').allowAnyInput
     );
     queryAndAssert(element, '#ccs').dispatchEvent(
       new CustomEvent('account-text-changed', {bubbles: true, composed: true})
     );
-    assert.isTrue(element._reviewersMutated);
+    assert.isTrue(element.reviewersMutated);
   });
 
   test('gets draft from storage on open', () => {
@@ -1323,17 +1335,19 @@
     const clock = sinon.useFakeTimers();
 
     const firstEdit = 'hello';
-    const location = element._getStorageLocation();
+    const location = element.getStorageLocation();
 
     element.draft = firstEdit;
     clock.tick(1000);
-    await flush();
+    await element.updateComplete;
+    await element.storeTask?.flush();
 
     assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
 
     element.draft = '';
     clock.tick(1000);
-    await flush();
+    await element.updateComplete;
+    await element.storeTask?.flush();
 
     assert.isTrue(eraseDraftCommentStub.calledWith(location));
   });
@@ -1341,6 +1355,7 @@
   test('400 converts to human-readable server-error', async () => {
     stubRestApi('saveChangeReview').callsFake(
       (_changeNum, _patchNum, _review, errFn) => {
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         errFn!(
           cloneableResponse(
             400,
@@ -1362,7 +1377,7 @@
     };
     addListenerForTest(document, 'server-error', listener);
 
-    await flush();
+    await element.updateComplete;
     element.send(false, false);
     await promise;
   });
@@ -1370,6 +1385,7 @@
   test('non-json 400 is treated as a normal server-error', async () => {
     stubRestApi('saveChangeReview').callsFake(
       (_changeNum, _patchNum, _review, errFn) => {
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         errFn!(cloneableResponse(400, 'Comment validation error!') as Response);
         return Promise.resolve(new Response());
       }
@@ -1387,7 +1403,7 @@
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    await flush();
+    await element.updateComplete;
     element.send(false, false);
     await promise;
   });
@@ -1398,11 +1414,11 @@
     const reviewer2 = makeGroup();
     const cc1 = makeAccount();
     const cc2 = makeGroup();
-    let filter = element._filterReviewerSuggestionGenerator(false);
+    let filter = element.filterReviewerSuggestionGenerator(false);
 
-    element._owner = owner;
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2];
+    element.owner = owner;
+    element.reviewers = [reviewer1, reviewer2];
+    element.ccs = [cc1, cc2];
 
     assert.isTrue(filter({account: makeAccount()} as Suggestion));
     assert.isTrue(filter({group: makeGroup()} as Suggestion));
@@ -1414,35 +1430,41 @@
     assert.isFalse(filter({account: reviewer1} as Suggestion));
     assert.isFalse(filter({group: reviewer2} as Suggestion));
 
-    filter = element._filterReviewerSuggestionGenerator(true);
+    filter = element.filterReviewerSuggestionGenerator(true);
 
     // Existing and pending CCs should be excluded when isCC = true;.
     assert.isFalse(filter({account: cc1} as Suggestion));
     assert.isFalse(filter({group: cc2} as Suggestion));
   });
 
-  test('_focusOn', async () => {
-    const chooseFocusTargetSpy = sinon.spy(element, '_chooseFocusTarget');
-    element._focusOn();
-    await flush();
+  test('focusOn', async () => {
+    await element.updateComplete;
+    const clock = sinon.useFakeTimers();
+    const chooseFocusTargetSpy = sinon.spy(element, 'chooseFocusTarget');
+    element.focusOn();
+    // element.focus() is called after a setTimeout(). The focusOn() method
+    // does not trigger any changes in the element hence element.updateComplete
+    // resolves immediately and cannot be used here, hence tick the clock here
+    // explicitly instead
+    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 1);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
     assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
 
-    element._focusOn(element.FocusTarget.ANY);
-    await flush();
+    element.focusOn(element.FocusTarget.ANY);
+    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
     assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
 
-    element._focusOn(element.FocusTarget.BODY);
-    await flush();
+    element.focusOn(element.FocusTarget.BODY);
+    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
     assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
 
-    element._focusOn(element.FocusTarget.REVIEWERS);
-    await flush();
+    element.focusOn(element.FocusTarget.REVIEWERS);
+    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(
       element?.shadowRoot?.activeElement?.tagName,
@@ -1450,44 +1472,45 @@
     );
     assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
 
-    element._focusOn(element.FocusTarget.CCS);
-    await flush();
+    element.focusOn(element.FocusTarget.CCS);
+    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(
       element?.shadowRoot?.activeElement?.tagName,
       'GR-ACCOUNT-LIST'
     );
     assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
+    clock.restore();
   });
 
-  test('_chooseFocusTarget', () => {
-    element._account = undefined;
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+  test('chooseFocusTarget', () => {
+    element.account = undefined;
+    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element._account = {_account_id: 1 as AccountId};
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+    element.account = {_account_id: 1 as AccountId};
+    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
     element.change!.owner = {_account_id: 2 as AccountId};
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
     element.change!.owner._account_id = 1 as AccountId;
     assert.strictEqual(
-      element._chooseFocusTarget(),
+      element.chooseFocusTarget(),
       element.FocusTarget.REVIEWERS
     );
 
-    element._reviewers = [];
+    element.reviewers = [];
     assert.strictEqual(
-      element._chooseFocusTarget(),
+      element.chooseFocusTarget(),
       element.FocusTarget.REVIEWERS
     );
 
-    element._reviewers.push({});
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+    element.reviewers.push({});
+    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
   });
 
   test('only send labels that have changed', async () => {
-    await flush();
+    await element.updateComplete;
     stubSaveReview((review: ReviewInput) => {
       assert.deepEqual(review?.labels, {
         'Code-Review': 0,
@@ -1503,18 +1526,16 @@
     // tap() cause a race in some situations in shadow DOM.
     // The send button can be tapped before the others, causing the test to
     // fail.
-    const el = queryAndAssert(
+    const el = queryAndAssert<GrLabelScoreRow>(
       queryAndAssert(element, 'gr-label-scores'),
       'gr-label-score-row[name="Verified"]'
-    ) as GrLabelScoreRow;
+    );
     el.setSelectedValue('-1');
     tap(queryAndAssert(element, '.send'));
     await promise;
   });
 
-  test('moving from cc to reviewer', () => {
-    flush();
-
+  test('moving from cc to reviewer', async () => {
     const reviewer1 = makeAccount();
     const reviewer2 = makeAccount();
     const reviewer3 = makeAccount();
@@ -1522,23 +1543,36 @@
     const cc2 = makeAccount();
     const cc3 = makeAccount();
     const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_reviewers', cc1);
-    flush();
+    element.reviewers = [reviewer1, reviewer2, reviewer3];
+    element.ccs = [cc1, cc2, cc3, cc4];
+    element.reviewers.push(cc1);
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: cc1},
+      })
+    );
+    await element.updateComplete;
 
-    assert.deepEqual(element._reviewers, [
-      reviewer1,
-      reviewer2,
-      reviewer3,
-      cc1,
-    ]);
-    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+    assert.deepEqual(element.reviewers, [reviewer1, reviewer2, reviewer3, cc1]);
+    assert.deepEqual(element.ccs, [cc2, cc3, cc4]);
 
-    element.push('_reviewers', cc4, cc3);
-    flush();
+    element.reviewers.push(cc4);
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: cc4},
+      })
+    );
+    await element.updateComplete;
 
-    assert.deepEqual(element._reviewers, [
+    element.reviewers.push(cc3);
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: cc3},
+      })
+    );
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [
       reviewer1,
       reviewer2,
       reviewer3,
@@ -1546,54 +1580,65 @@
       cc4,
       cc3,
     ]);
-    assert.deepEqual(element._ccs, [cc2]);
+    assert.deepEqual(element.ccs, [cc2]);
   });
 
-  test('update attention section when reviewers and ccs change', () => {
-    element._account = makeAccount();
-    element._reviewers = [makeAccount(), makeAccount()];
-    element._ccs = [makeAccount(), makeAccount()];
+  test('update attention section when reviewers and ccs change', async () => {
+    element.account = makeAccount();
+    element.reviewers = [makeAccount(), makeAccount()];
+    element.ccs = [makeAccount(), makeAccount()];
     element.draftCommentThreads = [];
+
     const modifyButton = queryAndAssert(element, '.edit-attention-button');
     tap(modifyButton);
-    flush();
 
-    // "Modify" button disabled, because "Send" button is disabled.
-    assert.isFalse(element._attentionExpanded);
+    await element.updateComplete;
+
+    assert.isFalse(element.attentionExpanded);
+
     element.draft = 'a test comment';
+    await element.updateComplete;
+
     tap(modifyButton);
-    flush();
-    assert.isTrue(element._attentionExpanded);
+
+    await element.updateComplete;
+
+    assert.isTrue(element.attentionExpanded);
 
     let accountLabels = Array.from(
       queryAll(element, '.attention-detail gr-account-label')
     );
     assert.equal(accountLabels.length, 5);
 
-    element.push('_reviewers', makeAccount());
-    element.push('_ccs', makeAccount());
-    flush();
+    element.reviewers = [...element.reviewers, makeAccount()];
+    element.ccs = [...element.ccs, makeAccount()];
+    await element.updateComplete;
 
     // The 'attention modified' section collapses and resets when reviewers or
     // ccs change.
-    assert.isFalse(element._attentionExpanded);
+    assert.isFalse(element.attentionExpanded);
 
     tap(queryAndAssert(element, '.edit-attention-button'));
-    flush();
+    await element.updateComplete;
 
-    assert.isTrue(element._attentionExpanded);
+    assert.isTrue(element.attentionExpanded);
     accountLabels = Array.from(
       queryAll(element, '.attention-detail gr-account-label')
     );
     assert.equal(accountLabels.length, 7);
 
-    element.pop('_reviewers');
-    element.pop('_reviewers');
-    element.pop('_ccs');
-    element.pop('_ccs');
+    element.reviewers.pop();
+    element.reviewers.pop();
+    element.ccs.pop();
+    element.ccs.pop();
+    element.reviewers = [...element.reviewers];
+    element.ccs = [...element.ccs]; // trigger willUpdate observer
+
+    await element.updateComplete;
 
     tap(queryAndAssert(element, '.edit-attention-button'));
-    flush();
+
+    await element.updateComplete;
 
     accountLabels = Array.from(
       queryAll(element, '.attention-detail gr-account-label')
@@ -1601,9 +1646,7 @@
     assert.equal(accountLabels.length, 3);
   });
 
-  test('moving from reviewer to cc', () => {
-    flush();
-
+  test('moving from reviewer to cc', async () => {
     const reviewer1 = makeAccount();
     const reviewer2 = makeAccount();
     const reviewer3 = makeAccount();
@@ -1611,19 +1654,38 @@
     const cc2 = makeAccount();
     const cc3 = makeAccount();
     const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_ccs', reviewer1);
-    flush();
+    element.reviewers = [reviewer1, reviewer2, reviewer3];
+    element.ccs = [cc1, cc2, cc3, cc4];
+    element.ccs.push(reviewer1);
+    element.ccsList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: reviewer1},
+      })
+    );
 
-    assert.deepEqual(element._reviewers, [reviewer2, reviewer3]);
-    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+    await element.updateComplete;
 
-    element.push('_ccs', reviewer3, reviewer2);
-    flush();
+    assert.deepEqual(element.reviewers, [reviewer2, reviewer3]);
+    assert.deepEqual(element.ccs, [cc1, cc2, cc3, cc4, reviewer1]);
 
-    assert.deepEqual(element._reviewers, []);
-    assert.deepEqual(element._ccs, [
+    element.ccs.push(reviewer3);
+    element.ccsList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: reviewer3},
+      })
+    );
+    await element.updateComplete;
+
+    element.ccs.push(reviewer2);
+    element.ccsList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: reviewer2},
+      })
+    );
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, []);
+    assert.deepEqual(element.ccs, [
       cc1,
       cc2,
       cc3,
@@ -1635,28 +1697,30 @@
   });
 
   test('migrate reviewers between states', async () => {
-    flush();
-    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
-    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    const reviewers = queryAndAssert<GrAccountList>(element, '#reviewers');
+    const ccs = queryAndAssert<GrAccountList>(element, '#ccs');
     const reviewer1 = makeAccount();
     const reviewer2 = makeAccount();
     const cc1 = makeAccount();
     const cc2 = makeAccount();
     const cc3 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2, cc3];
+    element.reviewers = [reviewer1, reviewer2];
+    element.ccs = [cc1, cc2, cc3];
 
     element.change!.reviewers = {
       [ReviewerState.CC]: [],
       [ReviewerState.REVIEWER]: [{_account_id: 33 as AccountId}],
     };
+    await element.updateComplete;
 
     const mutations: ReviewerInput[] = [];
 
     stubSaveReview((review: ReviewInput) => {
-      mutations.push(...review!.reviewers!);
+      mutations.push(...review.reviewers!);
     });
 
+    assert.isFalse(element.reviewersMutated);
+
     // Remove and add to other field.
     reviewers.dispatchEvent(
       new CustomEvent('remove', {
@@ -1665,7 +1729,10 @@
         bubbles: true,
       })
     );
-    ccs.$.entry.dispatchEvent(
+
+    await element.updateComplete;
+    assert.isTrue(element.reviewersMutated);
+    ccs.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: reviewer1}},
         composed: true,
@@ -1686,7 +1753,7 @@
         bubbles: true,
       })
     );
-    reviewers.$.entry.dispatchEvent(
+    reviewers.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: cc1}},
         composed: true,
@@ -1694,16 +1761,36 @@
       })
     );
 
-    // Add to other field without removing from former field.
-    // (Currently not possible in UI, but this is a good consistency check).
-    reviewers.$.entry.dispatchEvent(
+    assert.deepEqual(
+      element.reviewers.map(v => accountKey(v)),
+      [reviewer2, cc1].map(v => accountKey(v))
+    );
+    assert.deepEqual(
+      element.ccs.map(v => accountKey(v)),
+      [cc2, reviewer1].map(v => accountKey(v))
+    );
+
+    // Add to Reviewer/CC which will automatically remove from CC/Reviewer.
+    reviewers.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: cc2}},
         composed: true,
         bubbles: true,
       })
     );
-    ccs.$.entry.dispatchEvent(
+
+    await element.updateComplete;
+
+    assert.deepEqual(
+      element.reviewers.map(v => accountKey(v)),
+      [reviewer2, cc1, cc2].map(v => accountKey(v))
+    );
+    assert.deepEqual(
+      element.ccs.map(v => accountKey(v)),
+      [reviewer1].map(v => accountKey(v))
+    );
+
+    ccs.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: reviewer2}},
         composed: true,
@@ -1711,6 +1798,17 @@
       })
     );
 
+    await element.updateComplete;
+
+    assert.deepEqual(
+      element.reviewers.map(v => accountKey(v)),
+      [cc1, cc2].map(v => accountKey(v))
+    );
+    assert.deepEqual(
+      element.ccs.map(v => accountKey(v)),
+      [reviewer1, reviewer2].map(v => accountKey(v))
+    );
+
     const mapReviewer = function (
       reviewer: AccountInfo,
       opt_state?: ReviewerState
@@ -1726,7 +1824,9 @@
 
     // Send and purge and verify moves, delete cc3.
     await element.send(false, false);
-    expect(mutations).to.have.lengthOf(5);
+    await element.updateComplete;
+    assert.equal(mutations.length, 5);
+
     expect(mutations[0]).to.deep.equal(
       mapReviewer(cc1, ReviewerState.REVIEWER)
     );
@@ -1748,12 +1848,12 @@
   });
 
   test('Ignore removal requests if being added as reviewer/CC', async () => {
-    flush();
-    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
-    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    await element.updateComplete;
+    const reviewers = queryAndAssert<GrAccountList>(element, '#reviewers');
+    const ccs = queryAndAssert<GrAccountList>(element, '#ccs');
     const reviewer1 = makeAccount();
-    element._reviewers = [reviewer1];
-    element._ccs = [];
+    element.reviewers = [reviewer1];
+    element.ccs = [];
 
     element.change!.reviewers = {
       [ReviewerState.CC]: [],
@@ -1763,7 +1863,7 @@
     const mutations: ReviewerInput[] = [];
 
     stubSaveReview((review: ReviewInput) => {
-      mutations.push(...review!.reviewers!);
+      mutations.push(...review.reviewers!);
     });
 
     // Remove and add to other field.
@@ -1774,7 +1874,7 @@
         bubbles: true,
       })
     );
-    ccs.$.entry.dispatchEvent(
+    ccs.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: reviewer1}},
         composed: true,
@@ -1791,11 +1891,11 @@
     });
   });
 
-  test('emits cancel on esc key', () => {
+  test('emits cancel on esc key', async () => {
     const cancelHandler = sinon.spy();
     element.addEventListener('cancel', cancelHandler);
     pressAndReleaseKeyOn(element, 27, null, 'Escape');
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(cancelHandler.called);
   });
@@ -1814,26 +1914,30 @@
     await promise;
   });
 
-  test('_computeMessagePlaceholder', () => {
+  test('computeMessagePlaceholder', async () => {
+    element.canBeStarted = false;
+    await element.updateComplete;
+    assert.equal(element.messagePlaceholder, 'Say something nice...');
+
+    element.canBeStarted = true;
+    await element.updateComplete;
     assert.equal(
-      element._computeMessagePlaceholder(false),
-      'Say something nice...'
-    );
-    assert.equal(
-      element._computeMessagePlaceholder(true),
+      element.messagePlaceholder,
       'Add a note for your reviewers...'
     );
   });
 
-  test('_computeSendButtonLabel', () => {
-    assert.equal(element._computeSendButtonLabel(false), 'Send');
-    assert.equal(
-      element._computeSendButtonLabel(true),
-      'Send and Start review'
-    );
+  test('computeSendButtonLabel', async () => {
+    element.canBeStarted = false;
+    await element.updateComplete;
+    assert.equal(element.sendButtonLabel, 'Send');
+
+    element.canBeStarted = true;
+    await element.updateComplete;
+    assert.equal(element.sendButtonLabel, 'Send and Start review');
   });
 
-  test('_handle400Error reviewers and CCs', async () => {
+  test('handle400Error reviewers and CCs', async () => {
     const error1 = 'error 1';
     const error2 = 'error 2';
     const error3 = 'error 3';
@@ -1863,18 +1967,18 @@
       });
     };
     addListenerForTest(document, 'server-error', listener);
-    element._handle400Error(cloneableResponse(400, text) as Response);
+    element.handle400Error(cloneableResponse(400, text) as Response);
     await promise;
   });
 
   test('fires height change when the drafts comments load', async () => {
     // Flush DOM operations before binding to the autogrow event so we don't
     // catch the events fired from the initial layout.
-    await flush();
+    await element.updateComplete;
     const autoGrowHandler = sinon.stub();
     element.addEventListener('autogrow', autoGrowHandler);
     element.draftCommentThreads = [];
-    await flush();
+    await element.updateComplete;
     assert.isTrue(autoGrowHandler.called);
   });
 
@@ -1885,18 +1989,18 @@
       sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
       element.canBeStarted = true;
       // Flush to make both Start/Save buttons appear in DOM.
-      await flush();
+      await element.updateComplete;
     });
 
     test('start review sets ready', async () => {
       tap(queryAndAssert(element, '.send'));
-      await flush();
+      await element.updateComplete;
       assert.isTrue(sendStub.calledWith(true, true));
     });
 
     test("save review doesn't set ready", async () => {
       tap(queryAndAssert(element, '.save'));
-      await flush();
+      await element.updateComplete;
       assert.isTrue(sendStub.calledWith(true, false));
     });
   });
@@ -1923,7 +2027,7 @@
       assert.isFalse(element.disabled);
     }
 
-    test('error occurs in _saveReview', () => {
+    test('error occurs in saveReview', () => {
       stubSaveReview(() => {
         throw expectedError;
       });
@@ -1944,209 +2048,163 @@
         element.open();
 
         assert.isFalse(refreshSpy.called);
-        assert.isTrue(element._savingComments);
+        assert.isTrue(element.savingComments);
 
         promise.resolve();
-        await flush();
+        await element.updateComplete;
 
         assert.isTrue(refreshSpy.called);
-        assert.isFalse(element._savingComments);
+        assert.isFalse(element.savingComments);
       });
 
       test('no', () => {
         stubRestApi('hasPendingDiffDrafts').returns(0);
         element.open();
-        assert.isFalse(element._savingComments);
+        assert.isFalse(element.savingComments);
       });
     });
   });
 
-  test('_computeSendButtonDisabled_canBeStarted', () => {
+  test('computeSendButtonDisabled_canBeStarted', () => {
     // Mock canBeStarted
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ true,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = true;
+    element.draftCommentThreads = [];
+    element.draft = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_allFalse', () => {
+  test('computeSendButtonDisabled_allFalse', () => {
     // Mock everything false
-    assert.isTrue(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [];
+    element.draft = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+    assert.isTrue(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_draftCommentsSend', () => {
-    // Mock nonempty comment draft array, with sending comments.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([createComment()])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ true,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+  test('computeSendButtonDisabled_draftCommentsSend', () => {
+    // Mock nonempty comment draft array; with sending comments.
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.draft = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = true;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
-    // Mock nonempty comment draft array, without sending comments.
-    assert.isTrue(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([createComment()])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+  test('computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+    // Mock nonempty comment draft array; without sending comments.
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.draft = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isTrue(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_changeMessage', () => {
+  test('computeSendButtonDisabled_changeMessage', () => {
     // Mock nonempty change message.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([createComment()])},
-        ],
-        /* text= */ 'test',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.draft = 'test';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_reviewersChanged', () => {
+  test('computeSendButtonDisabledreviewersChanged', () => {
     // Mock reviewers mutated.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([createComment()])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ true,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.draft = '';
+    element.reviewersMutated = true;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_labelsChanged', () => {
+  test('computeSendButtonDisabled_labelsChanged', () => {
     // Mock labels changed.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([createComment()])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.draft = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = true;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_dialogDisabled', () => {
+  test('computeSendButtonDisabled_dialogDisabled', () => {
     // Whole dialog is disabled.
-    assert.isTrue(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([createComment()])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ true,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.draft = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = true;
+    element.includeComments = false;
+    element.disabled = true;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isTrue(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_existingVote', async () => {
+  test('computeSendButtonDisabled_existingVote', async () => {
     const account = createAccountWithId();
     (
       element.change!.labels![StandardLabels.CODE_REVIEW]! as DetailedLabelInfo
     ).all = [account];
-    await flush();
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.draft = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = account;
 
     // User has already voted.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([createComment()])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ account
-      )
-    );
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
   test('_submit blocked when no mutations exist', async () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
     element.draftCommentThreads = [];
-    await flush();
+    await element.updateComplete;
 
     tap(queryAndAssert(element, 'gr-button.send'));
     assert.isFalse(sendStub.called);
@@ -2163,7 +2221,7 @@
         ]),
       },
     ];
-    await flush();
+    await element.updateComplete;
 
     tap(queryAndAssert(element, 'gr-button.send'));
     assert.isTrue(sendStub.called);
@@ -2173,11 +2231,11 @@
     // Setting draftCommentThreads to an empty object causes _sendDisabled to be
     // computed to false.
     element.draftCommentThreads = [];
-    await flush();
+    await element.updateComplete;
 
     assert.equal(
-      element.getFocusStops().end,
-      queryAndAssert(element, '#cancelButton')
+      element.getFocusStops()!.end,
+      queryAndAssert<GrButton>(element, '#cancelButton')
     );
     element.draftCommentThreads = [
       {
@@ -2191,16 +2249,17 @@
         ]),
       },
     ];
-    await flush();
+    await element.updateComplete;
 
     assert.equal(
-      element.getFocusStops().end,
-      queryAndAssert(element, '#sendButton')
+      element.getFocusStops()!.end,
+      queryAndAssert<GrButton>(element, '#sendButton')
     );
   });
 
-  test('setPluginMessage', () => {
+  test('setPluginMessage', async () => {
     element.setPluginMessage('foo');
+    await element.updateComplete;
     assert.equal(queryAndAssert(element, '#pluginMessage').textContent, 'foo');
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 86754c0..d5ce654 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -17,138 +17,168 @@
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-vote-chip/gr-vote-chip';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-reviewer-list_html';
-import {customElement, property, computed, observe} from '@polymer/decorators';
+import {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+
 import {
   ChangeInfo,
-  LabelNameToValueMap,
   AccountInfo,
   ApprovalInfo,
-  Reviewers,
-  AccountId,
-  EmailAddress,
   AccountDetailInfo,
   isDetailedLabelInfo,
   LabelInfo,
 } from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {isRemovableReviewer} from '../../../utils/change-util';
-import {ReviewerState} from '../../../constants/constants';
-import {getAppContext} from '../../../services/app-context';
-import {fireAlert} from '../../../utils/event-util';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {nothing} from 'lit';
 
 @customElement('gr-reviewer-list')
-export class GrReviewerList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrReviewerList extends LitElement {
   /**
    * Fired when the "Add reviewer..." button is tapped.
    *
    * @event show-reply-dialog
    */
 
-  @property({type: Object})
-  change?: ChangeInfo;
+  @property({type: Object}) change?: ChangeInfo;
 
-  @property({type: Object})
-  account?: AccountDetailInfo;
+  @property({type: Object}) account?: AccountDetailInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
+  @property({type: Boolean, reflect: true}) disabled = false;
 
-  @property({type: Boolean})
-  mutable = false;
+  @property({type: Boolean}) mutable = false;
 
-  @property({type: Boolean})
-  reviewersOnly = false;
+  @property({type: Boolean, attribute: 'reviewers-only'}) reviewersOnly = false;
 
-  @property({type: Boolean})
-  ccsOnly = false;
+  @property({type: Boolean, attribute: 'ccs-only'}) ccsOnly = false;
 
-  @property({type: Array})
-  _displayedReviewers: AccountInfo[] = [];
+  @state() displayedReviewers: AccountInfo[] = [];
 
-  @property({type: Array})
-  _reviewers: AccountInfo[] = [];
+  @state() reviewers: AccountInfo[] = [];
 
-  @property({type: Boolean})
-  _showInput = false;
+  @state() hiddenReviewerCount?: number;
 
-  @property({type: Object})
-  _xhrPromise?: Promise<Response | undefined>;
+  @state() showAllReviewers = false;
 
-  private readonly restApiService = getAppContext().restApiService;
-
-  private readonly flagsService = getAppContext().flagsService;
-
-  @computed('ccsOnly')
-  get _addLabel() {
-    return this.ccsOnly ? 'Add CC' : 'Add reviewer';
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.8;
+          pointer-events: none;
+        }
+        .container {
+          display: block;
+          /* line-height-normal for the chips, 2px for the chip border, spacing-s
+            for the gap between lines, negative bottom margin for eliminating the
+            gap after the last line */
+          line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
+          margin-bottom: calc(0px - var(--spacing-s));
+        }
+        .addReviewer iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        .controlsContainer {
+          display: inline-block;
+        }
+        gr-button.addReviewer {
+          --gr-button-padding: 1px 0px;
+          vertical-align: top;
+          top: 1px;
+        }
+        gr-button {
+          line-height: var(--line-height-normal);
+          --gr-button-padding: 0px;
+        }
+        gr-account-chip {
+          line-height: var(--line-height-normal);
+          vertical-align: top;
+          display: inline-block;
+        }
+        gr-vote-chip {
+          --gr-vote-chip-width: 14px;
+          --gr-vote-chip-height: 14px;
+        }
+      `,
+    ];
   }
 
-  @computed('_reviewers', '_displayedReviewers')
-  get _hiddenReviewerCount() {
-    // Polymer 2: check for undefined
-    if (
-      this._reviewers === undefined ||
-      this._displayedReviewers === undefined
-    ) {
-      return undefined;
-    }
-    return this._reviewers.length - this._displayedReviewers.length;
+  override render() {
+    this.displayedReviewers = this.computeDisplayedReviewers() ?? [];
+    this.hiddenReviewerCount =
+      this.reviewers.length - this.displayedReviewers.length;
+    return html`
+      <div class="container">
+        <div>
+          ${this.displayedReviewers.map(reviewer =>
+            this.renderAccountChip(reviewer)
+          )}
+          <div class="controlsContainer" ?hidden=${!this.mutable}>
+            <gr-button
+              link
+              id="addReviewer"
+              class="addReviewer"
+              @click=${this.handleAddTap}
+              title=${this.ccsOnly ? 'Add CC' : 'Add reviewer'}
+              ><iron-icon icon="gr-icons:edit"></iron-icon
+            ></gr-button>
+          </div>
+        </div>
+        <gr-button
+          class="hiddenReviewers"
+          link=""
+          ?hidden=${!this.hiddenReviewerCount}
+          @click=${() => {
+            this.showAllReviewers = true;
+          }}
+          >and ${this.hiddenReviewerCount} more</gr-button
+        >
+      </div>
+    `;
   }
 
-  /**
-   * Converts change.permitted_labels to an array of hashes of label keys to
-   * numeric scores.
-   * Example:
-   * [{
-   *   'Code-Review': ['-1', ' 0', '+1']
-   * }]
-   * will be converted to
-   * [{
-   *   label: 'Code-Review',
-   *   scores: [-1, 0, 1]
-   * }]
-   */
-  _permittedLabelsToNumericScores(labels: LabelNameToValueMap | undefined) {
-    if (!labels) return [];
-    return Object.keys(labels).map(label => {
-      return {
-        label,
-        scores: labels[label].map(v => Number(v)),
-      };
-    });
+  private renderAccountChip(reviewer: AccountInfo) {
+    const change = this.change;
+    if (!change) return nothing;
+    return html`
+      <gr-account-chip
+        class="reviewer"
+        .account=${reviewer}
+        .change=${change}
+        highlightAttention
+        .voteableText=${this.computeVoteableText(reviewer)}
+        .vote=${this.computeVote(reviewer)}
+        .label=${this.computeCodeReviewLabel()}
+      >
+        <gr-vote-chip
+          slot="vote-chip"
+          .vote=${this.computeVote(reviewer)}
+          .label=${this.computeCodeReviewLabel()}
+          circle-shape
+        ></gr-vote-chip>
+      </gr-account-chip>
+    `;
   }
 
   /**
    * Returns max permitted score for reviewer.
    */
-  _getReviewerPermittedScore(
-    reviewer: AccountInfo,
-    change: ChangeInfo,
-    label: string
-  ) {
+  private getReviewerPermittedScore(reviewer: AccountInfo, label: string) {
     // Note (issue 7874): sometimes the "all" list is not included in change
     // detail responses, even when DETAILED_LABELS is included in options.
-    if (!change.labels) {
+    if (!this.change?.labels) {
       return NaN;
     }
-    const detailedLabel = change.labels[label];
+    const detailedLabel = this.change.labels[label];
     if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
       return NaN;
     }
@@ -166,13 +196,15 @@
     return NaN;
   }
 
-  _computeVoteableText(reviewer: AccountInfo, change: ChangeInfo) {
+  // private but used in tests
+  computeVoteableText(reviewer: AccountInfo) {
+    const change = this.change;
     if (!change || !change.labels) {
       return '';
     }
     const maxScores = [];
     for (const label of Object.keys(change.labels)) {
-      const maxScore = this._getReviewerPermittedScore(reviewer, change, label);
+      const maxScore = this.getReviewerPermittedScore(reviewer, label);
       if (isNaN(maxScore) || maxScore < 0) {
         continue;
       }
@@ -182,35 +214,23 @@
     return maxScores.join(', ');
   }
 
-  _computeVote(
-    reviewer: AccountInfo,
-    change?: ChangeInfo
-  ): ApprovalInfo | undefined {
-    const codeReviewLabel = this._computeCodeReviewLabel(change);
+  private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
+    const codeReviewLabel = this.computeCodeReviewLabel();
     if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
     return getApprovalInfo(codeReviewLabel, reviewer);
   }
 
-  _computeCodeReviewLabel(change?: ChangeInfo): LabelInfo | undefined {
-    if (!change || !change.labels) return;
-    return getCodeReviewLabel(change.labels);
+  private computeCodeReviewLabel(): LabelInfo | undefined {
+    if (!this.change?.labels) return;
+    return getCodeReviewLabel(this.change.labels);
   }
 
-  @observe('change.reviewers.*', 'change.owner')
-  _reviewersChanged(
-    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      changeRecord === undefined ||
-      owner === undefined ||
-      this.change === undefined
-    ) {
+  private computeDisplayedReviewers() {
+    if (this.change?.owner === undefined) {
       return;
     }
     let result: AccountInfo[] = [];
-    const reviewers = changeRecord.base;
+    const reviewers = this.change.reviewers;
     for (const key of Object.keys(reviewers)) {
       if (this.reviewersOnly && key !== 'REVIEWER') {
         continue;
@@ -222,68 +242,21 @@
         result = result.concat(reviewers[key]!);
       }
     }
-    this._reviewers = result
-      .filter(reviewer => reviewer._account_id !== owner._account_id)
+    this.reviewers = result
+      .filter(
+        reviewer => reviewer._account_id !== this.change?.owner._account_id
+      )
       .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
 
-    if (this._reviewers.length > 8) {
-      this._displayedReviewers = this._reviewers.slice(0, 6);
+    if (this.reviewers.length > 8 && !this.showAllReviewers) {
+      return this.reviewers.slice(0, 6);
     } else {
-      this._displayedReviewers = this._reviewers;
+      return this.reviewers;
     }
   }
 
-  _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      return false;
-    }
-    return mutable && isRemovableReviewer(this.change, reviewer);
-  }
-
-  _handleRemove(e: Event) {
-    e.preventDefault();
-    const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
-    if (!target.account || !this.change?.reviewers) return;
-    const accountID = target.account._account_id || target.account.email;
-    if (!accountID) return;
-    const reviewers = this.change.reviewers;
-    let removedAccount: AccountInfo | undefined;
-    let removedType: ReviewerState | undefined;
-    for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
-      const reviewerStateByType = reviewers[type] || [];
-      reviewers[type] = reviewerStateByType;
-      for (let i = 0; i < reviewerStateByType.length; i++) {
-        if (
-          reviewerStateByType[i]._account_id === accountID ||
-          reviewerStateByType[i].email === accountID
-        ) {
-          removedAccount = reviewerStateByType[i];
-          removedType = type;
-          this.splice(`change.reviewers.${type}`, i, 1);
-          break;
-        }
-      }
-    }
-    const curChange = this.change;
-    this.disabled = true;
-    this._xhrPromise = this._removeReviewer(accountID)
-      .then(response => {
-        this.disabled = false;
-        if (!this.change?.reviewers || this.change !== curChange) return;
-        if (!response?.ok) {
-          this.push(`change.reviewers.${removedType}`, removedAccount);
-          fireAlert(this, `Cannot remove a ${removedType}`);
-          return response;
-        }
-        return;
-      })
-      .catch((err: Error) => {
-        this.disabled = false;
-        throw err;
-      });
-  }
-
-  _handleAddTap(e: Event) {
+  // private but used in tests
+  handleAddTap(e: Event) {
     e.preventDefault();
     const value = {
       reviewersOnly: false,
@@ -303,19 +276,6 @@
       })
     );
   }
-
-  _handleViewAll() {
-    this._displayedReviewers = this._reviewers;
-  }
-
-  _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
-    if (!this.change) return Promise.resolve(undefined);
-    return this.restApiService.removeChangeReviewer(this.change._number, id);
-  }
-
-  showNewSubmitRequirements(change?: ChangeInfo) {
-    return showNewSubmitRequirements(this.flagsService, change);
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
deleted file mode 100644
index b697cd5..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.8;
-      pointer-events: none;
-    }
-    .container {
-      display: block;
-      /* line-height-normal for the chips, 2px for the chip border, spacing-s
-         for the gap between lines, negative bottom margin for eliminating the
-         gap after the last line */
-      line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
-      margin-bottom: calc(0px - var(--spacing-s));
-    }
-    .addReviewer iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .controlsContainer {
-      display: inline-block;
-    }
-    gr-button.addReviewer {
-      --gr-button-padding: 1px 0px;
-      vertical-align: top;
-      top: 1px;
-    }
-    gr-button {
-      line-height: var(--line-height-normal);
-      --gr-button-padding: 0px;
-    }
-    gr-account-chip {
-      line-height: var(--line-height-normal);
-      vertical-align: top;
-      display: inline-block;
-    }
-    gr-vote-chip {
-      --gr-vote-chip-width: 14px;
-      --gr-vote-chip-height: 14px;
-    }
-  </style>
-  <div class="container">
-    <div>
-      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-        <gr-account-chip
-          class="reviewer"
-          account="[[reviewer]]"
-          change="[[change]]"
-          on-remove="_handleRemove"
-          highlightAttention
-          voteable-text="[[_computeVoteableText(reviewer, change)]]"
-          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
-          vote="[[_computeVote(reviewer, change)]]"
-          label="[[_computeCodeReviewLabel(change)]]"
-        >
-          <template is="dom-if" if="[[showNewSubmitRequirements(change)]]">
-            <gr-vote-chip
-              slot="vote-chip"
-              vote="[[_computeVote(reviewer, change)]]"
-              label="[[_computeCodeReviewLabel(change)]]"
-              circle-shape
-            ></gr-vote-chip>
-          </template>
-        </gr-account-chip>
-      </template>
-      <div class="controlsContainer" hidden$="[[!mutable]]">
-        <gr-button
-          link=""
-          id="addReviewer"
-          class="addReviewer"
-          on-click="_handleAddTap"
-          title="[[_addLabel]]"
-          ><iron-icon icon="gr-icons:edit"></iron-icon
-        ></gr-button>
-      </div>
-    </div>
-    <gr-button
-      class="hiddenReviewers"
-      link=""
-      hidden$="[[!_hiddenReviewerCount]]"
-      on-click="_handleViewAll"
-      >and [[_hiddenReviewerCount]] more</gr-button
-    >
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index a5bcc3a..c6b1d5f 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -17,42 +17,38 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-reviewer-list';
-import {
-  mockPromise,
-  queryAndAssert,
-  stubRestApi,
-} from '../../../test/test-utils';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrReviewerList} from './gr-reviewer-list';
 import {
   createAccountDetailWithId,
   createChange,
   createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {AccountId, EmailAddress} from '../../../types/common';
-import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import './gr-reviewer-list';
 
 const basicFixture = fixtureFromElement('gr-reviewer-list');
 
 suite('gr-reviewer-list tests', () => {
   let element: GrReviewerList;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
-
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
+    await element.updateComplete;
   });
 
-  test('controls hidden on immutable element', () => {
-    flush();
+  test('controls hidden on immutable element', async () => {
     element.mutable = false;
+    await element.updateComplete;
+
     assert.isTrue(
       queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
     );
+
     element.mutable = true;
+    await element.updateComplete;
+
     assert.isFalse(
       queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
     );
@@ -63,171 +59,11 @@
     element.addEventListener('show-reply-dialog', () => {
       dialogShown.resolve();
     });
-    await flush();
-    tap(queryAndAssert(element, '.addReviewer'));
+    queryAndAssert<GrButton>(element, '.addReviewer').click();
     await dialogShown;
   });
 
-  test('only show remove for removable reviewers', async () => {
-    element.mutable = true;
-    element.change = {
-      ...createChange(),
-      owner: {
-        ...createAccountDetailWithId(1),
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            ...createAccountDetailWithId(2),
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com' as EmailAddress,
-          },
-          {
-            _account_id: 3 as AccountId,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            ...createAccountDetailWithId(4),
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com' as EmailAddress,
-          },
-          {
-            email: 'test@e.mail' as EmailAddress,
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3 as AccountId,
-          name: 'Pinky Penguin',
-        },
-        {
-          ...createAccountDetailWithId(4),
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com' as EmailAddress,
-        },
-        {
-          email: 'test@e.mail' as EmailAddress,
-        },
-      ],
-    };
-    await flush();
-    const chips = element.root!.querySelectorAll('gr-account-chip');
-    assert.equal(chips.length, 4);
-
-    for (const el of Array.from(chips)) {
-      const accountID = el.account!._account_id || el.account!.email;
-      assert.ok(accountID);
-
-      const buttonEl = queryAndAssert(el, 'gr-button');
-      if (accountID === 2) {
-        assert.isTrue(buttonEl.hasAttribute('hidden'));
-      } else {
-        assert.isFalse(buttonEl.hasAttribute('hidden'));
-      }
-    }
-  });
-
-  suite('_handleRemove', () => {
-    let removeReviewerStub: sinon.SinonStub;
-    let reviewersChangedSpy: sinon.SinonSpy;
-
-    const reviewerWithId = {
-      ...createAccountDetailWithId(2),
-      name: 'Some name',
-    };
-
-    const reviewerWithIdAndEmail = {
-      ...createAccountDetailWithId(4),
-      name: 'Some other name',
-      email: 'example@' as EmailAddress,
-    };
-
-    const reviewerWithEmailOnly = {
-      email: 'example2@example' as EmailAddress,
-    };
-
-    let chips: GrAccountChip[];
-
-    setup(() => {
-      removeReviewerStub = sinon
-        .stub(element, '_removeReviewer')
-        .returns(Promise.resolve(new Response()));
-      element.mutable = true;
-
-      const allReviewers = [
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ];
-
-      element.change = {
-        ...createChange(),
-        owner: {
-          ...createAccountDetailWithId(1),
-        },
-        reviewers: {
-          REVIEWER: allReviewers,
-        },
-        removable_reviewers: allReviewers,
-      };
-      flush();
-      chips = Array.from(element.root!.querySelectorAll('gr-account-chip'));
-      assert.equal(chips.length, allReviewers.length);
-      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
-    });
-
-    test('_handleRemove for account with accountId only', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!._account_id === reviewerWithId._account_id
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with accountId and email', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!._account_id === reviewerWithIdAndEmail._account_id
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(
-        removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id)
-      );
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with email only', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!.email === reviewerWithEmailOnly.email
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-      ]);
-    });
-  });
-
-  test('tracking reviewers and ccs', () => {
+  test('tracking reviewers and ccs', async () => {
     let counter = 0;
     function makeAccount() {
       return {_account_id: counter++ as AccountId};
@@ -249,7 +85,8 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [reviewer, cc]);
+    await element.updateComplete;
+    assert.deepEqual(element.reviewers, [reviewer, cc]);
 
     element.reviewersOnly = true;
     element.change = {
@@ -257,7 +94,9 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [reviewer]);
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [reviewer]);
 
     element.ccsOnly = true;
     element.reviewersOnly = false;
@@ -266,16 +105,18 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [cc]);
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [cc]);
   });
 
-  test('_handleAddTap passes mode with event', () => {
+  test('handleAddTap passes mode with event', () => {
     const fireStub = sinon.stub(element, 'dispatchEvent');
     const e = {...new Event(''), preventDefault() {}};
 
     element.ccsOnly = false;
     element.reviewersOnly = false;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {
@@ -285,7 +126,7 @@
     });
 
     element.reviewersOnly = true;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {reviewersOnly: true, ccsOnly: false},
@@ -293,14 +134,14 @@
 
     element.ccsOnly = true;
     element.reviewersOnly = false;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {ccsOnly: true, reviewersOnly: false},
     });
   });
 
-  test('dont show all reviewers button with 4 reviewers', () => {
+  test('dont show all reviewers button with 4 reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 4; i++) {
       reviewers.push({
@@ -320,15 +161,15 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 4);
-    assert.equal(element._reviewers.length, 4);
-    assert.isTrue(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
-    );
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 0);
+    assert.equal(element.displayedReviewers.length, 4);
+    assert.equal(element.reviewers.length, 4);
+    assert.isTrue(queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden);
   });
 
-  test('account owner comes first in list of reviewers', () => {
+  test('account owner comes first in list of reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 4; i++) {
       reviewers.push({
@@ -350,11 +191,12 @@
         REVIEWER: reviewers,
       },
     };
-    flush();
-    assert.equal(element._displayedReviewers[0]._account_id, 1 as AccountId);
+    await element.updateComplete;
+
+    assert.equal(element.displayedReviewers[0]._account_id, 1 as AccountId);
   });
 
-  test('show all reviewers button with 9 reviewers', () => {
+  test('show all reviewers button with 9 reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 9; i++) {
       reviewers.push({
@@ -374,15 +216,17 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 3);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 9);
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 3);
+    assert.equal(element.displayedReviewers.length, 6);
+    assert.equal(element.reviewers.length, 9);
     assert.isFalse(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+      queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden
     );
   });
 
-  test('show all reviewers button', () => {
+  test('show all reviewers button', async () => {
     const reviewers = [];
     for (let i = 0; i < 100; i++) {
       reviewers.push({
@@ -402,25 +246,28 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 94);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 100);
+
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 94);
+    assert.equal(element.displayedReviewers.length, 6);
+    assert.equal(element.reviewers.length, 100);
     assert.isFalse(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+      queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden
     );
 
-    tap(queryAndAssert(element, '.hiddenReviewers'));
+    queryAndAssert<GrButton>(element, '.hiddenReviewers').click();
 
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 100);
-    assert.equal(element._reviewers.length, 100);
-    assert.isTrue(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
-    );
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 0);
+    assert.equal(element.displayedReviewers.length, 100);
+    assert.equal(element.reviewers.length, 100);
+    assert.isTrue(queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden);
   });
 
-  test('votable labels', () => {
-    const change = {
+  test('votable labels', async () => {
+    element.change = {
       ...createChange(),
       labels: {
         Foo: {
@@ -455,30 +302,33 @@
         FooBar: ['-1', ' 0'],
       },
     };
+    await element.updateComplete;
+
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(1)}),
       'Bar: +1'
     );
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(7)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(7)}),
       'Foo: +2, Bar: +1, FooBar: 0'
     );
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(2)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(2)}),
       ''
     );
   });
 
-  test('fails gracefully when all is not included', () => {
-    const change = {
+  test('fails gracefully when all is not included', async () => {
+    element.change = {
       ...createChange(),
       labels: {Foo: {}},
       permitted_labels: {
         Foo: ['-1', ' 0', '+1', '+2'],
       },
     };
+    await element.updateComplete;
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(1)}),
       ''
     );
   });
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index decbe93..a3ef937 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -144,7 +144,7 @@
     return html` <div id="container" role="tooltip" tabindex="-1">
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
         </div>
         <div class="sectionContent">
           <h3 class="name heading-3">
@@ -237,7 +237,7 @@
       <gr-button
         link=""
         id="toggleConditionsButton"
-        @click="${(_: MouseEvent) => this.toggleConditionsVisibility()}"
+        @click=${(_: MouseEvent) => this.toggleConditionsVisibility()}
       >
         ${buttonText}
         <iron-icon icon="gr-icons:${icon}"></iron-icon
@@ -285,7 +285,7 @@
     return html` <div class="button quickApprove">
       <gr-button
         link=""
-        @click="${(_: MouseEvent) => this.quickApprove(labelName, maxVote)}"
+        @click=${(_: MouseEvent) => this.quickApprove(labelName, maxVote)}
       >
         ${this.computeVoteButtonName(labelName, maxVote, type)}
       </gr-button>
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index ff954cd..ded88de 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -40,9 +40,9 @@
   setup(async () => {
     element = await fixture<GrSubmitRequirementHovercard>(
       html`<gr-submit-requirement-hovercard
-        .requirement="${createSubmitRequirementResultInfo()}"
+        .requirement=${createSubmitRequirementResultInfo()}
         .change=${createChange()}
-        .account="${createAccountWithId()}"
+        .account=${createAccountWithId()}
       ></gr-submit-requirement-hovercard>`
     );
   });
@@ -151,10 +151,7 @@
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
       description: 'Test Description',
-      submittability_expression_result: {
-        ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
-      },
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
     const change: ParsedChangeInfo = {
       ...createParsedChange(),
@@ -238,10 +235,7 @@
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
       description: 'Test Description',
-      submittability_expression_result: {
-        ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
-      },
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
     const account = createAccountWithId();
     const change: ParsedChangeInfo = {
@@ -316,14 +310,11 @@
       const submitRequirement: SubmitRequirementResultInfo = {
         ...createSubmitRequirementResultInfo(),
         description: 'Test Description',
-        submittability_expression_result: {
-          ...createSubmitRequirementExpressionInfo(),
-          expression: 'label:Verified=MAX -label:Verified=MIN',
-        },
-        override_expression_result: {
-          ...createSubmitRequirementExpressionInfo(),
-          expression: 'label:Build-Cop=MAX',
-        },
+        submittability_expression_result:
+          createSubmitRequirementExpressionInfo(),
+        override_expression_result: createSubmitRequirementExpressionInfo(
+          'label:Build-Cop=MAX'
+        ),
       };
       const account = createAccountWithId();
       const change: ParsedChangeInfo = {
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 46e600e..08e3ab4 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -46,12 +46,14 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {CheckRun} from '../../../models/checks/checks-model';
 import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
-import {Category} from '../../../api/checks';
+import {Category, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import {PrimaryTab} from '../../../constants/constants';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
+import {join} from 'lit/directives/join';
+import {map} from 'lit/directives/map';
 
 /**
  * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -93,6 +95,7 @@
         iron-icon {
           width: var(--line-height-normal, 20px);
           height: var(--line-height-normal, 20px);
+          vertical-align: top;
         }
         .requirements,
         section.trigger-votes {
@@ -118,16 +121,15 @@
         td {
           padding: var(--spacing-s);
           white-space: nowrap;
+          vertical-align: top;
         }
         .votes-cell {
           display: flex;
+          flex-flow: wrap;
         }
-        .check-error {
-          margin-right: var(--spacing-l);
-        }
-        .check-error iron-icon {
-          color: var(--error-foreground);
-          vertical-align: top;
+        .votes-cell .separator {
+          width: 100%;
+          margin-top: var(--spacing-s);
         }
         gr-vote-chip {
           margin-right: var(--spacing-s);
@@ -142,11 +144,11 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
   }
@@ -182,10 +184,10 @@
             (requirement, index) => html`
               <gr-submit-requirement-hovercard
                 for="requirement-${index}-${charsOnly(requirement.name)}"
-                .requirement="${requirement}"
-                .change="${this.change}"
-                .account="${this.account}"
-                .mutable="${this.mutable ?? false}"
+                .requirement=${requirement}
+                .change=${this.change}
+                .account=${this.account}
+                .mutable=${this.mutable ?? false}
               ></gr-submit-requirement-hovercard>
             `
           )}
@@ -198,16 +200,11 @@
         <td class="name">
           <gr-limited-text
             class="name"
-            limit="25"
-            .text="${requirement.name}"
+            .text=${requirement.name}
           ></gr-limited-text>
         </td>
         <td>
-          ${this.renderEndpoint(
-            requirement,
-            html`${this.renderVotesAndChecksChips(requirement)}
-            ${this.renderOverrideLabels(requirement)}`
-          )}
+          ${this.renderEndpoint(requirement, this.renderVoteCell(requirement))}
         </td>
       </tr>
     `;
@@ -237,10 +234,7 @@
       return html`<div class="votes-cell">${slot}</div>`;
 
     const endpointName = this.calculateEndpointName(requirement.name);
-    return html`<gr-endpoint-decorator
-      class="votes-cell"
-      name="${endpointName}"
-    >
+    return html`<gr-endpoint-decorator class="votes-cell" name=${endpointName}>
       <gr-endpoint-param
         name="change"
         .value=${this.change}
@@ -256,43 +250,51 @@
   renderStatus(status: SubmitRequirementStatus) {
     const icon = iconForStatus(status);
     return html`<iron-icon
-      class="${icon}"
+      class=${icon}
       icon="gr-icons:${icon}"
       role="img"
-      aria-label="${status.toLowerCase()}"
+      aria-label=${status.toLowerCase()}
     ></iron-icon>`;
   }
 
-  renderVotesAndChecksChips(requirement: SubmitRequirementResultInfo) {
+  renderVoteCell(requirement: SubmitRequirementResultInfo) {
     if (requirement.status === SubmitRequirementStatus.ERROR) {
       return html`<span class="error">Error</span>`;
     }
+
     const requirementLabels = extractAssociatedLabels(requirement);
     const allLabels = this.change?.labels ?? {};
     const associatedLabels = Object.keys(allLabels).filter(label =>
       requirementLabels.includes(label)
     );
 
-    const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
-      label => !hasVotes(allLabels[label])
-    );
-
-    const checksChips = this.renderChecks(requirement);
-
     const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
     if (requirementWithoutLabelToVoteOn) {
       const status = capitalizeFirstLetter(requirement.status.toLowerCase());
-      return checksChips || html`${status}`;
+      return this.renderChecks(requirement) || html`${status}`;
     }
 
+    const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
+      label => !hasVotes(allLabels[label])
+    );
     if (everyAssociatedLabelsIsWithoutVotes) {
-      return checksChips || html`No votes`;
+      return this.renderChecks(requirement) || html`No votes`;
     }
 
-    return html`${associatedLabels.map(label =>
-      this.renderLabelVote(label, allLabels)
+    const associatedLabelsWithVotes = associatedLabels.filter(label =>
+      hasVotes(allLabels[label])
+    );
+
+    return html`${join(
+      map(
+        associatedLabelsWithVotes,
+        label =>
+          html`${this.renderLabelVote(label, allLabels)}
+          ${this.renderOverrideLabels(requirement, label)}`
+      ),
+      html`<span class="separator"></span>`
     )}
-    ${checksChips}`;
+    ${this.renderChecks(requirement)}`;
   }
 
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -304,64 +306,101 @@
       return uniqueApprovals.map(
         approvalInfo =>
           html`<gr-vote-chip
-            .vote="${approvalInfo}"
-            .label="${labelInfo}"
-            .more="${(labelInfo.all ?? []).filter(
+            .vote=${approvalInfo}
+            .label=${labelInfo}
+            .more=${(labelInfo.all ?? []).filter(
               other => other.value === approvalInfo.value
-            ).length > 1}"
+            ).length > 1}
           ></gr-vote-chip>`
       );
     } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${labelInfo}"></gr-vote-chip>`];
+      return [html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`];
     } else {
       return html``;
     }
   }
 
-  renderChecks(requirement: SubmitRequirementResultInfo) {
-    const requirementLabels = extractAssociatedLabels(requirement);
-    const requirementRuns = this.runs
-      .filter(run => hasResultsOf(run, Category.ERROR))
-      .filter(
-        run => run.labelName && requirementLabels.includes(run.labelName)
-      );
-    const runsCount = requirementRuns.reduce(
-      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
-      0
-    );
-    if (runsCount === 0) return;
-    const links = [];
-    if (requirementRuns.length === 1 && requirementRuns[0].statusLink) {
-      links.push(requirementRuns[0].statusLink);
-    }
-    return html`<gr-checks-chip
-      .text=${`${runsCount}`}
-      .links=${links}
-      .statusOrCategory=${Category.ERROR}
-      @click="${() => {
-        fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
-          checksTab: {
-            statusOrCategory: Category.ERROR,
-          },
-        });
-      }}"
-    ></gr-checks-chip>`;
-  }
-
-  renderOverrideLabels(requirement: SubmitRequirementResultInfo) {
+  renderOverrideLabels(
+    requirement: SubmitRequirementResultInfo,
+    forLabel: string
+  ) {
     if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
     const requirementLabels = extractAssociatedLabels(
       requirement,
       'onlyOverride'
-    ).filter(label => {
-      const allLabels = this.change?.labels ?? {};
-      return allLabels[label] && hasVotes(allLabels[label]);
-    });
+    )
+      .filter(label => label === forLabel)
+      .filter(label => {
+        const allLabels = this.change?.labels ?? {};
+        return allLabels[label] && hasVotes(allLabels[label]);
+      });
     return requirementLabels.map(
       label => html`<span class="overrideLabel">${label}</span>`
     );
   }
 
+  renderChecks(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(requirement);
+    const errorRuns = this.runs
+      .filter(run => hasResultsOf(run, Category.ERROR))
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+    const errorRunsCount = errorRuns.reduce(
+      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
+      0
+    );
+    if (errorRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        errorRuns,
+        errorRunsCount,
+        Category.ERROR
+      );
+    }
+    const runningRuns = this.runs
+      .filter(r => r.isLatestAttempt)
+      .filter(
+        r => r.status === RunStatus.RUNNING || r.status === RunStatus.SCHEDULED
+      )
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+
+    const runningRunsCount = runningRuns.length;
+    if (runningRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        runningRuns,
+        runningRunsCount,
+        RunStatus.RUNNING
+      );
+    }
+    return;
+  }
+
+  renderChecksCategoryChip(
+    runs: CheckRun[],
+    runsCount: Number,
+    category: Category | RunStatus
+  ) {
+    if (runsCount === 0) return;
+    const links = [];
+    if (runs.length === 1 && runs[0].statusLink) {
+      links.push(runs[0].statusLink);
+    }
+    return html`<gr-checks-chip
+      .text=${`${runsCount}`}
+      .links=${links}
+      .statusOrCategory=${category}
+      @click=${() => {
+        fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
+          checksTab: {
+            statusOrCategory: category,
+          },
+        });
+      }}
+    ></gr-checks-chip>`;
+  }
+
   renderTriggerVotes() {
     const labels = this.change?.labels ?? {};
     const triggerVotes = getTriggerVotes(this.change).filter(label =>
@@ -373,11 +412,11 @@
         ${triggerVotes.map(
           label =>
             html`<gr-trigger-vote
-              .label="${label}"
-              .labelInfo="${labels[label]}"
-              .change="${this.change}"
-              .account="${this.account}"
-              .mutable="${this.mutable ?? false}"
+              .label=${label}
+              .labelInfo=${labels[label]}
+              .change=${this.change}
+              .account=${this.account}
+              .mutable=${this.mutable ?? false}
               .disableHovercards=${this.disableHovercards}
             ></gr-trigger-vote>`
         )}
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 0487316..09e601c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -28,9 +28,15 @@
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
   createNonApplicableSubmitRequirementResultInfo,
+  createRunResult,
+  createCheckResult,
 } from '../../../test/test-data-generators';
-import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
 import {ParsedChangeInfo} from '../../../types/types';
+import {RunStatus} from '../../../api/checks';
 
 suite('gr-submit-requirements tests', () => {
   let element: GrSubmitRequirements;
@@ -39,10 +45,7 @@
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
       description: 'Test Description',
-      submittability_expression_result: {
-        ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
-      },
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
     change = {
       ...createParsedChange(),
@@ -96,7 +99,7 @@
               </iron-icon>
             </td>
             <td class="name">
-              <gr-limited-text class="name" limit="25"></gr-limited-text>
+              <gr-limited-text class="name"></gr-limited-text>
             </td>
             <td>
               <gr-endpoint-decorator
@@ -147,7 +150,7 @@
 
     test('without label to vote on', async () => {
       const modifiedChange = {...change};
-      modifiedChange.submit_requirements![0]!.submittability_expression_result!.expression =
+      modifiedChange.submit_requirements![0]!.submittability_expression_result.expression =
         'hasfooter:"Release-Notes"';
       element.change = modifiedChange;
       await element.updateComplete;
@@ -156,6 +159,117 @@
         <div class="votes-cell">Satisfied</div>
       `);
     });
+
+    test('checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">
+          <gr-vote-chip></gr-vote-chip>
+          <gr-checks-chip></gr-checks-chip>
+        </div>
+      `);
+    });
+
+    test('running checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          status: RunStatus.RUNNING,
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">
+          <gr-vote-chip></gr-vote-chip>
+          <gr-checks-chip></gr-checks-chip>
+        </div>
+      `);
+    });
+
+    test('with override label', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX -label:Override=MIN'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `<div class="votes-cell">
+        <gr-vote-chip> </gr-vote-chip>
+        <span class="overrideLabel"> Override </span>
+      </div>`);
+    });
+
+    test('with override with 2 labels', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+        Override2: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX label:Override2=MAX'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `<div class="votes-cell">
+        <gr-vote-chip> </gr-vote-chip>
+        <span class="overrideLabel"> Override </span>
+        <span class="separator"></span>
+        <gr-vote-chip> </gr-vote-chip>
+        <span class="overrideLabel"> Override2 </span>
+      </div>`);
+    });
   });
 
   test('calculateEndpointName()', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 477f579..0ba2e89 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -205,15 +205,23 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
-    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
   }
 
   override willUpdate(changed: PropertyValues) {
@@ -330,19 +338,19 @@
         <span class="sort-text">Sort By:</span>
         <gr-dropdown-list
           id="sortDropdown"
-          .value="${this.sortDropdownValue}"
-          @value-change="${(e: CustomEvent) =>
-            (this.sortDropdownValue = e.detail.value)}"
-          .items="${this.getSortDropdownEntries()}"
+          .value=${this.sortDropdownValue}
+          @value-change=${(e: CustomEvent) =>
+            (this.sortDropdownValue = e.detail.value)}
+          .items=${this.getSortDropdownEntries()}
         >
         </gr-dropdown-list>
         <span class="separator"></span>
         <span class="filter-text">Filter By:</span>
         <gr-dropdown-list
           id="filterDropdown"
-          .value="${this.getCommentsDropdownValue()}"
-          @value-change="${this.handleCommentsDropdownValueChange}"
-          .items="${this.getCommentsDropdownEntries()}"
+          .value=${this.getCommentsDropdownValue()}
+          @value-change=${this.handleCommentsDropdownValueChange}
+          .items=${this.getCommentsDropdownEntries()}
         >
         </gr-dropdown-list>
         ${this.renderAuthorChips()}
@@ -362,7 +370,7 @@
       <gr-button
         class="show-resolved-comments"
         link
-        @click="${this.handleAllComments}"
+        @click=${this.handleAllComments}
         >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
       >
     `;
@@ -398,16 +406,16 @@
   private renderCommentThread(thread: CommentThread, isFirst: boolean) {
     return html`
       <gr-comment-thread
-        .thread="${thread}"
+        .thread=${thread}
         show-file-path
-        ?show-ported-comment="${thread.ported}"
-        ?show-comment-context="${this.showCommentContext}"
-        ?show-file-name="${isFirst}"
-        .messageId="${this.messageId}"
-        ?should-scroll-into-view="${thread.rootId === this.scrollCommentId}"
-        @comment-thread-editing-changed="${() => {
+        ?show-ported-comment=${thread.ported}
+        ?show-comment-context=${this.showCommentContext}
+        ?show-file-name=${isFirst}
+        .messageId=${this.messageId}
+        ?should-scroll-into-view=${thread.rootId === this.scrollCommentId}
+        @comment-thread-editing-changed=${() => {
           this.requestUpdate();
-        }}"
+        }}
       ></gr-comment-thread>
     `;
   }
@@ -426,11 +434,11 @@
     );
     return html`
       <gr-account-label
-        .account="${account}"
-        @click="${this.handleAccountClicked}"
+        .account=${account}
+        @click=${this.handleAccountClicked}
         selectionChipStyle
         noStatusIcons
-        ?selected="${selected}"
+        ?selected=${selected}
       ></gr-account-label>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
index 41964c2..f2bd350 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -133,12 +133,12 @@
       );
       return approvals.map(
         approvalInfo => html`<gr-vote-chip
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
+          .vote=${approvalInfo}
+          .label=${labelInfo}
         ></gr-vote-chip>`
       );
     } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
+      return [html`<gr-vote-chip .label=${this.labelInfo}></gr-vote-chip>`];
     } else {
       return html``;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
index 2d2ded6..8497ff4 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
@@ -38,10 +38,7 @@
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
       description: 'Test Description',
-      submittability_expression_result: {
-        ...createSubmitRequirementExpressionInfo(),
-        expression: 'label:Verified=MAX -label:Verified=MIN',
-      },
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
     const change: ParsedChangeInfo = {
       ...createParsedChange(),
@@ -66,11 +63,11 @@
     const labelInfo = change?.labels?.[label];
     element = await fixture<GrTriggerVote>(
       html`<gr-trigger-vote
-        .label="${label}"
-        .labelInfo="${labelInfo}"
-        .change="${change}"
-        .account="${account}"
-        .mutable="${false}"
+        .label=${label}
+        .labelInfo=${labelInfo}
+        .change=${change}
+        .account=${account}
+        .mutable=${false}
       ></gr-trigger-vote>`
     );
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 29fe368..6097cde 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -63,9 +63,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.action.disabled}"
+        ?disabled=${this.action.disabled}
         class="action"
-        @click="${(e: Event) => this.handleClick(e)}"
+        @click=${(e: Event) => this.handleClick(e)}
       >
         ${this.action.name}
       </gr-button>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 85c0e4a..b9beb1d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -114,9 +114,13 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().labels$, x => (this.labels = x));
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().labels$,
+      x => (this.labels = x)
+    );
   }
 
   static override get styles() {
@@ -333,16 +337,16 @@
       `;
     }
     return html`
-      <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
-        <td class="nameCol" @click="${this.toggleExpandedClick}">
+      <tr class=${classMap({container: true, collapsed: !this.isExpanded})}>
+        <td class="nameCol" @click=${this.toggleExpandedClick}>
           <div class="flex">
-            <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
+            <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
             <div
               class="name"
               role="button"
               tabindex="0"
-              @click="${this.toggleExpandedClick}"
-              @keydown="${this.toggleExpandedPress}"
+              @click=${this.toggleExpandedClick}
+              @keydown=${this.toggleExpandedPress}
             >
               ${this.result.checkName}
             </div>
@@ -353,7 +357,7 @@
           <div class="summary-cell">
             ${this.renderLink(firstPrimaryLink(this.result))}
             ${this.renderSummary(this.result.summary)}
-            <div class="message" @click="${this.toggleExpandedClick}">
+            <div class="message" @click=${this.toggleExpandedClick}>
               ${this.isExpanded ? '' : this.result.message}
             </div>
             ${this.renderLinks()} ${this.renderActions()}
@@ -363,27 +367,27 @@
             ${this.renderLabel()}
           </div>
         </td>
-        <td class="expanderCol" @click="${this.toggleExpandedClick}">
+        <td class="expanderCol" @click=${this.toggleExpandedClick}>
           <div
             class="show-hide"
             role="switch"
             tabindex="0"
-            ?hidden="${!this.isExpandable}"
-            aria-checked="${this.isExpanded ? 'true' : 'false'}"
-            aria-label="${this.isExpanded
+            ?hidden=${!this.isExpandable}
+            aria-checked=${this.isExpanded ? 'true' : 'false'}
+            aria-label=${this.isExpanded
               ? 'Collapse result row'
-              : 'Expand result row'}"
-            @keydown="${this.toggleExpandedPress}"
+              : 'Expand result row'}
+            @keydown=${this.toggleExpandedPress}
           >
             <iron-icon
-              icon="${this.isExpanded
+              icon=${this.isExpanded
                 ? 'gr-icons:expand-less'
-                : 'gr-icons:expand-more'}"
+                : 'gr-icons:expand-more'}
             ></iron-icon>
           </div>
         </td>
       </tr>
-      <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+      <tr class=${classMap({detailsRow: true, collapsed: !this.isExpanded})}>
         <td class="expandedCol" colspan="3">${this.renderExpanded()}</td>
       </tr>
     `;
@@ -392,7 +396,7 @@
   private renderExpanded() {
     if (!this.isExpanded) return;
     return html`<gr-result-expanded
-      .result="${this.result}"
+      .result=${this.result}
     ></gr-result-expanded>`;
   }
 
@@ -437,7 +441,7 @@
     return html`
       <!-- The &nbsp; is for being able to shrink a tiny amount without
        the text itself getting shrunk with an ellipsis. -->
-      <div class="summary" @click="${this.toggleExpanded}">${text}&nbsp;</div>
+      <div class="summary" @click=${this.toggleExpanded}>${text}&nbsp;</div>
     `;
   }
 
@@ -457,7 +461,7 @@
     return html`
       <div class="label ${status}">
         <span>${label} ${valueStr}</span>
-        <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
+        <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
           The check result has (probably) influenced this label vote.
         </paper-tooltip>
       </div>
@@ -484,7 +488,7 @@
     if (this.isExpanded) return;
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" class="link" target="_blank"
+    return html`<a href=${link.url} class="link" target="_blank"
       ><iron-icon
         aria-label="external link to details"
         class="link"
@@ -510,12 +514,12 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        @opened-changed="${(e: CustomEvent) =>
-          toggleClass(this, 'dropdown-open', e.detail.value)}"
-        ?hidden="${overflowItems.length === 0}"
-        .items="${overflowItems}"
-        .disabledIds="${disabledItems}"
+        @tap-item=${this.handleAction}
+        @opened-changed=${(e: CustomEvent) =>
+          toggleClass(this, 'dropdown-open', e.detail.value)}
+        ?hidden=${overflowItems.length === 0}
+        .items=${overflowItems}
+        .disabledIds=${disabledItems}
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -536,7 +540,7 @@
     if (!action) return;
     return html`<gr-checks-action
       context="result-row"
-      .action="${action}"
+      .action=${action}
     ></gr-checks-action>`;
   }
 
@@ -561,10 +565,10 @@
   renderTag(tag: Tag) {
     return html`<button
       class="tag ${tag.color}"
-      @click="${(e: MouseEvent) => this.tagClick(e, tag.name)}"
+      @click=${(e: MouseEvent) => this.tagClick(e, tag.name)}
     >
       <span>${tag.name}</span>
-      <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
+      <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
         ${tag.tooltip ??
         'A category tag for this check result. Click to filter.'}
       </paper-tooltip>
@@ -608,11 +612,11 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getConfigModel().repoConfig$,
+      () => this.getConfigModel().repoConfig$,
       x => (this.repoConfig = x)
     );
   }
@@ -624,21 +628,18 @@
       ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
       <gr-endpoint-decorator
         name="check-result-expanded"
-        .targetPlugin="${this.result.pluginName}"
+        .targetPlugin=${this.result.pluginName}
       >
-        <gr-endpoint-param
-          name="run"
-          .value="${this.result}"
-        ></gr-endpoint-param>
+        <gr-endpoint-param name="run" .value=${this.result}></gr-endpoint-param>
         <gr-endpoint-param
           name="result"
-          .value="${this.result}"
+          .value=${this.result}
         ></gr-endpoint-param>
         <gr-formatted-text
           noTrailingMargin
           class="message"
-          .content="${this.result.message}"
-          .config="${this.repoConfig?.commentlinks}"
+          .content=${this.result.message}
+          .config=${this.repoConfig?.commentlinks}
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
@@ -697,7 +698,7 @@
     if (!link) return;
     const text = link.tooltip ?? tooltipForLink(link.icon);
     const target = targetBlank ? '_blank' : undefined;
-    return html`<a href="${link.url}" target="${ifDefined(target)}">
+    return html`<a href=${link.url} target=${ifDefined(target)}>
       <iron-icon
         class="link"
         icon="gr-icons:${iconForLink(link.icon)}"
@@ -790,31 +791,31 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsSelected$,
+      () => this.getChecksModel().topLevelActionsSelected$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelLinksSelected$,
+      () => this.getChecksModel().topLevelLinksSelected$,
       x => (this.links = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingSelected$,
+      () => this.getChecksModel().someProvidersAreLoadingSelected$,
       x => (this.someProvidersAreLoading = x)
     );
   }
@@ -1056,27 +1057,27 @@
       notLatest: !!this.checksPatchsetNumber,
     };
     return html`
-      <div class="${classMap(headerClasses)}">
+      <div class=${classMap(headerClasses)}>
         <div class="headerTopRow">
           <div class="left">
             <h2 class="heading-2">Results</h2>
-            <div class="loading" ?hidden="${!this.someProvidersAreLoading}">
+            <div class="loading" ?hidden=${!this.someProvidersAreLoading}>
               <span>Loading results </span>
               <span class="loadingSpin"></span>
             </div>
           </div>
           <div class="right">
             <div class="goToLatest">
-              <gr-button @click="${this.goToLatestPatchset}" link
+              <gr-button @click=${this.goToLatestPatchset} link
                 >Go to latest patchset</gr-button
               >
             </div>
             <gr-dropdown-list
-              value="${this.checksPatchsetNumber ??
+              value=${this.checksPatchsetNumber ??
               this.latestPatchsetNumber ??
-              0}"
-              .items="${this.createPatchsetDropdownItems()}"
-              @value-change="${this.onPatchsetSelected}"
+              0}
+              .items=${this.createPatchsetDropdownItems()}
+              @value-change=${this.onPatchsetSelected}
             ></gr-dropdown-list>
           </div>
         </div>
@@ -1141,9 +1142,9 @@
   private renderLink(link?: Link) {
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" target="_blank"
+    return html`<a href=${link.url} target="_blank"
       ><iron-icon
-        aria-label="${tooltipText}"
+        aria-label=${tooltipText}
         class="link"
         icon="gr-icons:${iconForLink(link.icon)}"
       ></iron-icon
@@ -1159,9 +1160,9 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        .items="${items}"
-        .disabledIds="${disabledIds}"
+        @tap-item=${this.handleAction}
+        .items=${items}
+        .disabledIds=${disabledIds}
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -1190,7 +1191,7 @@
     if (!action) return;
     return html`<gr-checks-action
       context="results"
-      .action="${action}"
+      .action=${action}
     ></gr-checks-action>`;
   }
 
@@ -1241,7 +1242,7 @@
           id="filterInput"
           type="text"
           placeholder="Filter results by tag or regular expression"
-          @input="${this.onFilterInputChange}"
+          @input=${this.onFilterInputChange}
         />
       </div>
     `;
@@ -1295,12 +1296,12 @@
       resultCount
     );
     return html`
-      <div class="${expandedClass}">
+      <div class=${expandedClass}>
         <h3
           class="categoryHeader ${catString} ${empty} heading-3"
-          @click="${() => this.toggleExpanded(category)}"
+          @click=${() => this.toggleExpanded(category)}
         >
-          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <iron-icon class="expandIcon" icon=${icon}></iron-icon>
           <div class="statusIconWrapper">
             <iron-icon
               icon="gr-icons:${iconFor(category)}"
@@ -1336,7 +1337,7 @@
     return html`
       <tr class="showAllRow">
         <td colspan="3">
-          <gr-button class="showAll" link @click="${handler}"
+          <gr-button class="showAll" link @click=${handler}
             >${message}</gr-button
           >
         </td>
@@ -1386,7 +1387,7 @@
       <table class="resultsTable">
         <thead>
           <tr class="headerRow">
-            <th class="${classMap(nameColClasses)}">Run</th>
+            <th class=${classMap(nameColClasses)}>Run</th>
             <th class="summaryCol">Summary</th>
             <th class="expanderCol"></th>
           </tr>
@@ -1397,8 +1398,8 @@
             result => result.internalResultId,
             (result?: RunResult) => html`
               <gr-result-row
-                class="${charsOnly(result!.checkName)}"
-                .result="${result}"
+                class=${charsOnly(result!.checkName)}
+                .result=${result}
               ></gr-result-row>
             `
           )}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index bf77c1b..414be21 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -245,35 +245,35 @@
 
     return html`
       <div
-        @click="${this.handleChipClick}"
-        @keydown="${this.handleChipKey}"
-        class="${classMap(classes)}"
+        @click=${this.handleChipClick}
+        @keydown=${this.handleChipKey}
+        class=${classMap(classes)}
         tabindex="0"
       >
         <div class="left">
-          <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
+          <gr-hovercard-run .run=${this.run}></gr-hovercard-run>
           ${this.renderFilterIcon()}
-          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
           ${this.renderETA()}
         </div>
         <div class="middle">
-          <gr-checks-attempt .run="${this.run}"></gr-checks-attempt>
+          <gr-checks-attempt .run=${this.run}></gr-checks-attempt>
           ${this.renderStatusLink()}
         </div>
         <div class="right">
           ${action
             ? html`<gr-checks-action
                 context="runs"
-                .action="${action}"
+                .action=${action}
               ></gr-checks-action>`
             : ''}
         </div>
       </div>
       <div
         class="attemptDetails"
-        ?hidden="${this.run.isSingleAttempt || !this.selected}"
+        ?hidden=${this.run.isSingleAttempt || !this.selected}
       >
         ${this.run.attemptDetails.map(a => this.renderAttempt(a))}
       </div>
@@ -295,14 +295,14 @@
     return html`<div class="attemptDetail">
       <input
         type="radio"
-        id="${id}"
-        name="${`${checkNameId}-attempt-choice`}"
-        ?checked="${this.isSelected(detail)}"
-        ?disabled="${!this.isSelected(detail) && wasNotRun}"
-        @change="${() => this.handleAttemptChange(detail)}"
+        id=${id}
+        name=${`${checkNameId}-attempt-choice`}
+        ?checked=${this.isSelected(detail)}
+        ?disabled=${!this.isSelected(detail) && wasNotRun}
+        @change=${() => this.handleAttemptChange(detail)}
       />
-      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
-      <label for="${id}">
+      <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
+      <label for=${id}>
         Attempt ${detail.attempt}${wasNotRun ? ' (not run)' : ''}
       </label>
     </div>`;
@@ -317,6 +317,8 @@
   renderETA() {
     if (this.run.status !== RunStatus.RUNNING) return;
     if (!this.run.finishedTimestamp) return;
+    const now = new Date();
+    if (this.run.finishedTimestamp.getTime() < now.getTime()) return;
     const eta = durationString(new Date(), this.run.finishedTimestamp, true);
     return html`<span class="eta">ETA: ${eta}</span>`;
   }
@@ -325,7 +327,7 @@
     const link = this.run.statusLink;
     if (!link) return;
     return html`
-      <a href="${link}" target="_blank" @click="${this.onLinkClick}"
+      <a href=${link} target="_blank" @click=${this.onLinkClick}
         ><iron-icon
           class="statusLinkIcon"
           icon="gr-icons:launch"
@@ -362,7 +364,7 @@
     if (!category) return nothing;
     const icon = iconFor(category);
     return html`
-      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+      <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
     `;
   }
 
@@ -427,21 +429,21 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
   }
@@ -597,8 +599,8 @@
         id="filterInput"
         type="text"
         placeholder="Filter runs by regular expression"
-        ?hidden="${!this.showFilter()}"
-        @input="${this.onInput}"
+        ?hidden=${!this.showFilter()}
+        @input=${this.onInput}
       />
       ${this.renderSection(RunStatus.RUNNING)}
       ${this.renderSection(RunStatus.COMPLETED)}
@@ -641,7 +643,7 @@
           Sign in to Checks Plugin to see runs and results
         </div>
         <div class="buttonRow">
-          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+          <gr-button @click=${this.loginCallback} link>Sign in</gr-button>
         </div>
       </div>
     `;
@@ -664,20 +666,20 @@
       <gr-button
         class="font-normal"
         link
-        @click="${() => fireRunSelectionReset(this)}"
+        @click=${() => fireRunSelectionReset(this)}
         >Unselect All</gr-button
       >
       <gr-tooltip-content
-        title="${runButtonDisabled
+        title=${runButtonDisabled
           ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
-          : ''}"
+          : ''}
         ?has-tooltip=${runButtonDisabled}
       >
         <gr-button
           class="font-normal"
           link
           ?disabled=${runButtonDisabled}
-          @click="${() => {
+          @click=${() => {
             actions.forEach(action => {
               if (!action) return;
               this.getChecksModel().triggerAction(
@@ -689,7 +691,7 @@
             this.reporting.reportInteraction(
               Interaction.CHECKS_RUNS_SELECTED_TRIGGERED
             );
-          }}"
+          }}
           >Run Selected</gr-button
         >
       </gr-tooltip-content>
@@ -700,22 +702,22 @@
     return html`
       <gr-tooltip-content
         has-tooltip
-        title="${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}"
+        title=${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}
       >
         <gr-button
           link
           class="expandButton"
           role="switch"
-          aria-checked="${this.collapsed ? 'true' : 'false'}"
-          aria-label="${this.collapsed
+          aria-checked=${this.collapsed ? 'true' : 'false'}
+          aria-label=${this.collapsed
             ? 'Expand runs panel'
-            : 'Collapse runs panel'}"
-          @click="${this.toggleCollapsed}"
+            : 'Collapse runs panel'}
+          @click=${this.toggleCollapsed}
           ><iron-icon
             class="expandIcon"
-            icon="${this.collapsed
+            icon=${this.collapsed
               ? 'gr-icons:chevron-right'
-              : 'gr-icons:chevron-left'}"
+              : 'gr-icons:chevron-left'}
           ></iron-icon>
         </gr-button>
       </gr-tooltip-content>
@@ -781,11 +783,8 @@
     }
     return html`
       <div class="${status.toLowerCase()} ${expandedClass}">
-        <div
-          class="sectionHeader"
-          @click="${() => this.toggleExpanded(status)}"
-        >
-          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+        <div class="sectionHeader" @click=${() => this.toggleExpanded(status)}>
+          <iron-icon class="expandIcon" icon=${icon}></iron-icon>
           <h3 class="heading-3">${header}</h3>
         </div>
         <div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
@@ -808,10 +807,10 @@
     const selectedAttempt = this.selectedAttempts.get(run.checkName);
     const deselected = !selectedRun && this.selectedRuns.length > 0;
     return html`<gr-checks-run
-      .run="${run}"
-      .selected="${selectedRun}"
-      .selectedAttempt="${selectedAttempt}"
-      .deselected="${deselected}"
+      .run=${run}
+      .selected=${selectedRun}
+      .selectedAttempt=${selectedAttempt}
+      .deselected=${deselected}
     ></gr-checks-run>`;
   }
 
@@ -824,39 +823,31 @@
     return html`
       <div class="testing">
         <div>Toggle fake runs by clicking buttons:</div>
-        <gr-button
-          link
-          @click="${() => clearAllFakeRuns(this.getChecksModel())}"
+        <gr-button link @click=${() => clearAllFakeRuns(this.getChecksModel())}
           >none</gr-button
         >
         <gr-button
           link
-          @click="${() =>
-            this.toggle(
-              'f0',
-              [fakeRun0],
-              fakeActions,
-              fakeLinks,
-              'ETA: 1 min'
-            )}"
+          @click=${() =>
+            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks, 'ETA: 1 min')}
           >0</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f1', [fakeRun1])}"
+        <gr-button link @click=${() => this.toggle('f1', [fakeRun1])}
           >1</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f2', [fakeRun2])}"
+        <gr-button link @click=${() => this.toggle('f2', [fakeRun2])}
           >2</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f3', [fakeRun3])}"
+        <gr-button link @click=${() => this.toggle('f3', [fakeRun3])}
           >3</gr-button
         >
         <gr-button link @click="${() => this.toggle('f4', fakeRun4Att)}}"
           >4</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f5', [fakeRun5])}"
+        <gr-button link @click=${() => this.toggle('f5', [fakeRun5])}
           >5</gr-button
         >
-        <gr-button link @click="${() => setAllFakeRuns(this.getChecksModel())}"
+        <gr-button link @click=${() => setAllFakeRuns(this.getChecksModel())}
           >all</gr-button
         >
       </div>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 2f9592f..bb34dec 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -73,31 +73,31 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().allResultsSelected$,
+      () => this.getChecksModel().allResultsSelected$,
       x => (this.results = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
   }
@@ -133,21 +133,21 @@
       <div class="container">
         <gr-checks-runs
           class="runs"
-          ?collapsed="${this.offsetWidth < 1000}"
-          .runs="${this.runs}"
-          .selectedRuns="${this.selectedRuns}"
-          .selectedAttempts="${this.selectedAttempts}"
-          .tabState="${this.tabState?.checksTab}"
-          @run-selected="${this.handleRunSelected}"
-          @attempt-selected="${this.handleAttemptSelected}"
+          ?collapsed=${this.offsetWidth < 1000}
+          .runs=${this.runs}
+          .selectedRuns=${this.selectedRuns}
+          .selectedAttempts=${this.selectedAttempts}
+          .tabState=${this.tabState?.checksTab}
+          @run-selected=${this.handleRunSelected}
+          @attempt-selected=${this.handleAttemptSelected}
         ></gr-checks-runs>
         <gr-checks-results
           class="results"
-          .tabState="${this.tabState?.checksTab}"
-          .runs="${this.runs}"
-          .selectedRuns="${this.selectedRuns}"
-          .selectedAttempts="${this.selectedAttempts}"
-          @run-selected="${this.handleRunSelected}"
+          .tabState=${this.tabState?.checksTab}
+          .runs=${this.runs}
+          .selectedRuns=${this.selectedRuns}
+          .selectedAttempts=${this.selectedAttempts}
+          @run-selected=${this.handleRunSelected}
         ></gr-checks-results>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index b42c4fd..a602c72 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -129,19 +129,19 @@
     const cat = this.result.category.toLowerCase();
     return html`
       <div class="${cat} container font-normal">
-        <div class="header" @click="${this.toggleExpandedClick}">
+        <div class="header" @click=${this.toggleExpandedClick}>
           <div class="icon">
             <iron-icon
               icon="gr-icons:${iconFor(this.result.category)}"
             ></iron-icon>
           </div>
           <div class="name">
-            <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
+            <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
             <div
               class="name"
               role="button"
               tabindex="0"
-              @keydown="${this.toggleExpandedPress}"
+              @keydown=${this.toggleExpandedPress}
             >
               ${this.result.checkName}
             </div>
@@ -166,16 +166,16 @@
         class="show-hide"
         role="switch"
         tabindex="0"
-        aria-checked="${this.isExpanded ? 'true' : 'false'}"
-        aria-label="${this.isExpanded
+        aria-checked=${this.isExpanded ? 'true' : 'false'}
+        aria-label=${this.isExpanded
           ? 'Collapse result row'
-          : 'Expand result row'}"
-        @keydown="${this.toggleExpandedPress}"
+          : 'Expand result row'}
+        @keydown=${this.toggleExpandedPress}
       >
         <iron-icon
-          icon="${this.isExpanded
+          icon=${this.isExpanded
             ? 'gr-icons:expand-less'
-            : 'gr-icons:expand-more'}"
+            : 'gr-icons:expand-more'}
         ></iron-icon>
       </div>
     `;
@@ -186,7 +186,7 @@
     return html`
       <gr-result-expanded
         hidecodepointers
-        .result="${this.result}"
+        .result=${this.result}
       ></gr-result-expanded>
     `;
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 51409e7..1be249d 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -29,8 +29,8 @@
     await element.updateComplete;
   });
 
-  teardown(async () => {
-    element?.remove();
+  teardown(() => {
+    if (element) element.remove();
   });
 
   test('renders', async () => {
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index ecc24c4..cc38693 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -137,7 +137,7 @@
       <div id="container" role="tooltip" tabindex="-1">
         <div class="section">
           <div
-            ?hidden="${!this.run || this.run.status === RunStatus.RUNNABLE}"
+            ?hidden=${!this.run || this.run.status === RunStatus.RUNNABLE}
             class="chipRow"
           >
             <div class="chip">
@@ -147,8 +147,8 @@
           </div>
         </div>
         <div class="section">
-          <div class="sectionIcon" ?hidden="${icon.length === 0}">
-            <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <div class="sectionIcon" ?hidden=${icon.length === 0}>
+            <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
           </div>
           <div class="sectionContent">
             <h3 class="name heading-3">
@@ -177,7 +177,7 @@
             ? html` <div class="row">
                 <div class="title">Status</div>
                 <div>
-                  <a href="${this.run.statusLink}" target="_blank"
+                  <a href=${this.run.statusLink} target="_blank"
                     ><iron-icon
                       aria-label="external link to check status"
                       class="small link"
@@ -222,7 +222,7 @@
       <div>
         <div class="attemptIcon">
           <iron-icon
-            class="${attempt.icon}"
+            class=${attempt.icon}
             icon="gr-icons:${attempt.icon}"
           ></iron-icon>
         </div>
@@ -320,7 +320,7 @@
             ? html` <div class="row">
                 <div class="title">Documentation</div>
                 <div>
-                  <a href="${this.run.checkLink}" target="_blank"
+                  <a href=${this.run.checkLink} target="_blank"
                     ><iron-icon
                       aria-label="external link to check documentation"
                       class="small link"
@@ -344,8 +344,8 @@
           <div class="action">
             <gr-checks-action
               context="hovercard"
-              .eventTarget="${this._target}"
-              .action="${action}"
+              .eventTarget=${this._target}
+              .action=${action}
             ></gr-checks-action>
           </div>
         `
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index b55d5a4..fbe1c60 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -97,16 +97,16 @@
   override render() {
     return html`<gr-dropdown
       link=""
-      .items="${this.links}"
-      .topContent="${this.topContent}"
+      .items=${this.links}
+      .topContent=${this.topContent}
       @tap-item-shortcuts=${this._handleShortcutsTap}
       .horizontalAlign=${'right'}
     >
-      <span ?hidden="${this._hasAvatars}"
+      <span ?hidden=${this._hasAvatars}
         >${this._accountName(this.account)}</span
       >
       <gr-avatar
-        .account="${this.account}"
+        .account=${this.account}
         ?hidden=${!this._hasAvatars}
         .imageSize=${56}
         aria-label="Account avatar"
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index ba0984b..a0f286f 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -88,7 +88,7 @@
 
     return html`
       <gr-button id="signIn" class="signInLink" link="" slot="footer">
-        <a class="signInLink" href="${this.loginUrl}">Sign in</a>
+        <a class="signInLink" href=${this.loginUrl}>Sign in</a>
       </gr-button>
     `;
   }
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
index 79e92b2..b42b20a 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
@@ -35,7 +34,7 @@
     const dismissCalled = mockPromise();
     element.addEventListener('dismiss', () => dismissCalled.resolve());
     MockInteractions.tap(
-      (queryAndAssert(element, '#dialog') as GrDialog).confirmButton!
+      queryAndAssert<GrDialog>(element, '#dialog').confirmButton!
     );
     await dismissCalled;
   });
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 54c8b89..d777c16 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -19,12 +19,9 @@
 import '../gr-error-dialog/gr-error-dialog';
 import '../../shared/gr-alert/gr-alert';
 import '../../shared/gr-overlay/gr-overlay';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-error-manager_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getAppContext} from '../../../services/app-context';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {customElement, property} from '@polymer/decorators';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
 import {GrAlert} from '../../shared/gr-alert/gr-alert';
@@ -40,6 +37,8 @@
 import {windowLocationReload} from '../../../utils/dom-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {fireIronAnnounce} from '../../../utils/event-util';
+import {LitElement, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 
 const HIDE_ALERT_TIMEOUT_MS = 10 * 1000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -67,14 +66,6 @@
 
 export const __testOnly_ErrorType = ErrorType;
 
-export interface GrErrorManager {
-  $: {
-    noInteractionOverlay: GrOverlay;
-    errorDialog: GrErrorDialog;
-    errorOverlay: GrOverlay;
-  };
-}
-
 export function constructServerErrorMsg({
   errorText,
   status,
@@ -107,32 +98,29 @@
 }
 
 @customElement('gr-error-manager')
-export class GrErrorManager extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrErrorManager extends LitElement {
   /**
    * The ID of the account that was logged in when the app was launched. If
    * not set, then there was no account at launch.
    */
-  @property({type: Number})
-  knownAccountId?: AccountId | null;
+  @state() knownAccountId?: AccountId | null;
 
-  @property({type: Object})
-  _alertElement: GrAlert | null = null;
+  @state() alertElement: GrAlert | null = null;
 
-  @property({type: Number})
-  _hideAlertHandle: number | null = null;
+  @state() hideAlertHandle: number | null = null;
 
-  @property({type: Boolean})
-  _refreshingCredentials = false;
+  @state() refreshingCredentials = false;
+
+  @query('#noInteractionOverlay') noInteractionOverlay!: GrOverlay;
+
+  @query('#errorDialog') errorDialog!: GrErrorDialog;
+
+  @query('#errorOverlay') errorOverlay!: GrOverlay;
 
   /**
    * The time (in milliseconds) since the most recent credential check.
    */
-  @property({type: Number})
-  _lastCredentialCheck: number = Date.now();
+  @state() lastCredentialCheck: number = Date.now();
 
   @property({type: String})
   loginUrl = '/login';
@@ -143,7 +131,7 @@
 
   private readonly eventEmitter = getAppContext().eventEmitter;
 
-  _authErrorHandlerDeregistrationHook?: Function;
+  private authErrorHandlerDeregistrationHook?: Function;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -159,10 +147,10 @@
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
     document.addEventListener('show-auth-required', this.handleAuthRequired);
 
-    this._authErrorHandlerDeregistrationHook = this.eventEmitter.on(
+    this.authErrorHandlerDeregistrationHook = this.eventEmitter.on(
       'auth-error',
       event => {
-        this._handleAuthError(event.message, event.action);
+        this.handleAuthError(event.message, event.action);
       }
     );
 
@@ -172,7 +160,7 @@
   }
 
   override disconnectedCallback() {
-    this._clearHideAlertHandle();
+    this.clearHideAlertHandle();
     document.removeEventListener(
       EventType.SERVER_ERROR,
       this.handleServerError
@@ -191,26 +179,46 @@
     document.removeEventListener('show-auth-required', this.handleAuthRequired);
     this.checkLoggedInTask?.cancel();
 
-    if (this._authErrorHandlerDeregistrationHook) {
-      this._authErrorHandlerDeregistrationHook();
+    if (this.authErrorHandlerDeregistrationHook) {
+      this.authErrorHandlerDeregistrationHook();
     }
     super.disconnectedCallback();
   }
 
-  _shouldSuppressError(msg: string) {
+  override render() {
+    return html`
+      <gr-overlay with-backdrop="" id="errorOverlay">
+        <gr-error-dialog
+          id="errorDialog"
+          @dismiss=${() => this.errorOverlay.close()}
+          .loginUrl=${this.loginUrl}
+        ></gr-error-dialog>
+      </gr-overlay>
+      <gr-overlay
+        id="noInteractionOverlay"
+        with-backdrop=""
+        always-on-top=""
+        no-cancel-on-esc-key=""
+        no-cancel-on-outside-click=""
+      >
+      </gr-overlay>
+    `;
+  }
+
+  private shouldSuppressError(msg: string) {
     return msg.includes(TOO_MANY_FILES);
   }
 
   private readonly handleAuthRequired = () => {
-    this._showAuthErrorAlert(
+    this.showAuthErrorAlert(
       'Log in is required to perform that action.',
       'Log in.'
     );
   };
 
-  _handleAuthError(msg: string, action: string) {
-    this.$.noInteractionOverlay.open().then(() => {
-      this._showAuthErrorAlert(msg, action);
+  private handleAuthError(msg: string, action: string) {
+    this.noInteractionOverlay.open().then(() => {
+      this.showAuthErrorAlert(msg, action);
     });
   }
 
@@ -237,11 +245,11 @@
         // Re-check on auth
         this._authService.clearCache();
         this.restApiService.getLoggedIn();
-      } else if (!this._shouldSuppressError(errorText)) {
+      } else if (!this.shouldSuppressError(errorText)) {
         const trace =
           response.headers && response.headers.get('X-Gerrit-Trace');
         if (response.status === 404) {
-          this._showNotFoundMessageWithTip({
+          this.showNotFoundMessageWithTip({
             status,
             statusText,
             errorText,
@@ -249,9 +257,9 @@
             trace,
           });
         } else if (response.status === 429) {
-          this._showQuotaExceeded({status, statusText});
+          this.showQuotaExceeded({status, statusText});
         } else {
-          this._showErrorDialog(
+          this.showErrorDialog(
             constructServerErrorMsg({
               status,
               statusText,
@@ -266,7 +274,7 @@
     });
   };
 
-  _showNotFoundMessageWithTip({
+  private showNotFoundMessageWithTip({
     status,
     statusText,
     errorText,
@@ -277,7 +285,7 @@
       const tip = isLoggedIn
         ? 'You might have not enough privileges.'
         : 'You might have not enough privileges. Sign in and try again.';
-      this._showErrorDialog(
+      this.showErrorDialog(
         constructServerErrorMsg({
           status,
           statusText,
@@ -293,10 +301,10 @@
     });
   }
 
-  _showQuotaExceeded({status, statusText}: ErrorMsg) {
+  private showQuotaExceeded({status, statusText}: ErrorMsg) {
     const tip = 'Try again later';
     const errorText = 'Too many requests from this client';
-    this._showErrorDialog(
+    this.showErrorDialog(
       constructServerErrorMsg({
         status,
         statusText,
@@ -324,7 +332,8 @@
 
   // TODO(dhruvsr): allow less priority alerts to override high priority alerts
   // In some use cases we may want generic alerts to show along/over errors
-  _canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
+  // private but used in tests
+  canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
     return ErrorTypePriority[incoming] >= ErrorTypePriority[existing];
   }
 
@@ -336,70 +345,72 @@
     type?: ErrorType,
     showDismiss?: boolean
   ) {
-    if (this._alertElement) {
+    if (this.alertElement) {
       // check priority before hiding
-      if (!this._canOverride(type, this._alertElement.type)) return;
+      if (!this.canOverride(type, this.alertElement.type)) return;
       this.hideAlert();
     }
 
-    this._clearHideAlertHandle();
+    this.clearHideAlertHandle();
     if (dismissOnNavigation) {
       // Persist alert until navigation.
       document.addEventListener('location-change', this.hideAlert);
     } else {
-      this._hideAlertHandle = window.setTimeout(
+      this.hideAlertHandle = window.setTimeout(
         this.hideAlert,
         HIDE_ALERT_TIMEOUT_MS
       );
     }
-    const el = this._createToastAlert(showDismiss);
+    const el = this.createToastAlert(showDismiss);
     el.show(text, actionText, actionCallback);
-    this._alertElement = el;
+    this.alertElement = el;
     fireIronAnnounce(this, `Alert: ${text}`);
     this.reporting.reportInteraction('show-alert', {text});
   }
 
   private readonly hideAlert = () => {
-    if (!this._alertElement) {
+    if (!this.alertElement) {
       return;
     }
 
-    this._alertElement.hide();
-    this._alertElement = null;
+    this.alertElement.hide();
+    this.alertElement = null;
 
     // Remove listener for page navigation, if it exists.
     document.removeEventListener('location-change', this.hideAlert);
   };
 
-  _clearHideAlertHandle() {
-    if (this._hideAlertHandle !== null) {
-      window.clearTimeout(this._hideAlertHandle);
-      this._hideAlertHandle = null;
+  private clearHideAlertHandle() {
+    if (this.hideAlertHandle !== null) {
+      window.clearTimeout(this.hideAlertHandle);
+      this.hideAlertHandle = null;
     }
   }
 
-  _showAuthErrorAlert(errorText: string, actionText?: string) {
+  // private but used in tests
+  showAuthErrorAlert(errorText: string, actionText?: string) {
     // hide any existing alert like `reload`
     // as auth error should have the highest priority
-    if (this._alertElement) {
-      this._alertElement.hide();
+    if (this.alertElement) {
+      this.alertElement.hide();
     }
 
-    this._alertElement = this._createToastAlert();
-    this._alertElement.type = ErrorType.AUTH;
-    this._alertElement.show(errorText, actionText, () =>
-      this._createLoginPopup()
+    this.alertElement = this.createToastAlert();
+    this.alertElement.type = ErrorType.AUTH;
+    this.alertElement.show(errorText, actionText, () =>
+      this.createLoginPopup()
     );
     fireIronAnnounce(this, errorText);
     this.reporting.reportInteraction('show-auth-error', {text: errorText});
-    this._refreshingCredentials = true;
-    this._requestCheckLoggedIn();
+    this.refreshingCredentials = true;
+    this.requestCheckLoggedIn();
     if (!document.hidden) {
       this.handleVisibilityChange();
     }
   }
 
-  _createToastAlert(showDismiss?: boolean) {
+  // private but used in tests
+  createToastAlert(showDismiss?: boolean) {
     const el = document.createElement('gr-alert');
     el.toast = true;
     el.showDismiss = !!showDismiss;
@@ -413,49 +424,51 @@
     // If not currently refreshing credentials and the credentials are old,
     // request them to confirm their validity or (display an auth toast if it
     // fails).
-    const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+    const timeSinceLastCheck = Date.now() - this.lastCredentialCheck;
     if (
-      !this._refreshingCredentials &&
+      !this.refreshingCredentials &&
       this.knownAccountId !== undefined &&
       timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS
     ) {
       this.reporting.reportInteraction('visibility-sign-in-check');
-      this._lastCredentialCheck = Date.now();
+      this.lastCredentialCheck = Date.now();
 
       // check auth status in case:
       // - user signed out
       // - user switched account
-      this._checkSignedIn();
+      this.checkSignedIn();
     }
   };
 
-  _requestCheckLoggedIn() {
+  // private but used in tests
+  requestCheckLoggedIn() {
     this.checkLoggedInTask = debounce(
       this.checkLoggedInTask,
-      () => this._checkSignedIn(),
+      () => this.checkSignedIn(),
       CHECK_SIGN_IN_INTERVAL_MS
     );
   }
 
-  _checkSignedIn() {
-    this._lastCredentialCheck = Date.now();
+  // private but used in tests
+  checkSignedIn() {
+    this.lastCredentialCheck = Date.now();
 
     // force to refetch account info
     this.restApiService.invalidateAccountsCache();
     this._authService.clearCache();
 
     this.restApiService.getLoggedIn().then(isLoggedIn => {
-      if (!this._refreshingCredentials) return;
+      if (!this.refreshingCredentials) return;
 
       if (!isLoggedIn) {
         // check later
         // 1. guest mode
         // 2. or signed out
         // in case #2, auth-error is taken care of separately
-        this._requestCheckLoggedIn();
+        this.requestCheckLoggedIn();
       } else {
         this.restApiService.getAccount().then(account => {
-          if (this._refreshingCredentials) {
+          if (this.refreshingCredentials) {
             // If the credentials were refreshed but the account is different,
             // then reload the page completely.
             if (account?._account_id !== this.knownAccountId) {
@@ -463,7 +476,7 @@
                 oldAccount: !!this.knownAccountId,
                 newAccount: !!account?._account_id,
               });
-              this._reloadPage();
+              this.reloadPage();
               return;
             }
 
@@ -474,11 +487,11 @@
     });
   }
 
-  _reloadPage() {
+  reloadPage() {
     windowLocationReload();
   }
 
-  _createLoginPopup() {
+  private createLoginPopup() {
     const left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
     const top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
     const options = [
@@ -495,12 +508,13 @@
     window.addEventListener('focus', this.handleWindowFocus);
   }
 
+  // private but used in tests
   handleCredentialRefreshed() {
     window.removeEventListener('focus', this.handleWindowFocus);
-    this._refreshingCredentials = false;
+    this.refreshingCredentials = false;
     this.hideAlert();
     this._showAlert('Credentials refreshed.');
-    this.$.noInteractionOverlay.close();
+    this.noInteractionOverlay.close();
 
     // Clear the cache for auth
     this._authService.clearCache();
@@ -511,19 +525,15 @@
   };
 
   private readonly handleShowErrorDialog = (e: ShowErrorEvent) => {
-    this._showErrorDialog(e.detail.message);
+    this.showErrorDialog(e.detail.message);
   };
 
-  _handleDismissErrorDialog() {
-    this.$.errorOverlay.close();
-  }
-
-  _showErrorDialog(message: string, options?: {showSignInButton?: boolean}) {
+  // private but used in tests
+  showErrorDialog(message: string, options?: {showSignInButton?: boolean}) {
     this.reporting.reportErrorDialog(message);
-    this.$.errorDialog.text = message;
-    this.$.errorDialog.showSignInButton =
-      !!options && !!options.showSignInButton;
-    this.$.errorOverlay.open();
+    this.errorDialog.text = message;
+    this.errorDialog.showSignInButton = !!options && !!options.showSignInButton;
+    this.errorOverlay.open();
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
deleted file mode 100644
index c67ed07..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
+++ /dev/null
@@ -1,37 +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`
-  <gr-overlay with-backdrop="" id="errorOverlay">
-    <gr-error-dialog
-      id="errorDialog"
-      on-dismiss="_handleDismissErrorDialog"
-      confirm-label="Dismiss"
-      confirm-on-enter=""
-      login-url="[[loginUrl]]"
-    ></gr-error-dialog>
-  </gr-overlay>
-  <gr-overlay
-    id="noInteractionOverlay"
-    with-backdrop=""
-    always-on-top=""
-    no-cancel-on-esc-key=""
-    no-cancel-on-outside-click=""
-  >
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
index d81be15..79321e1 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -31,8 +31,8 @@
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {AccountId} from '../../../types/common';
 import {waitUntil} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-error-manager');
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
 
 suite('gr-error-manager tests', () => {
   let element: GrErrorManager;
@@ -43,7 +43,7 @@
     let getLoggedInStub: sinon.SinonStub;
     let appContext: AppContext;
 
-    setup(() => {
+    setup(async () => {
       fetchStub = stubAuth('fetch').returns(
         Promise.resolve({...new Response(), ok: true, status: 204})
       );
@@ -54,9 +54,12 @@
       stubRestApi('getPreferences').returns(
         Promise.resolve(createPreferences())
       );
-      element = basicFixture.instantiate();
+      element = await fixture<GrErrorManager>(
+        html`<gr-error-manager></gr-error-manager>`
+      );
       appContext.authService.clearCache();
-      toastSpy = sinon.spy(element, '_createToastAlert');
+      toastSpy = sinon.spy(element, 'createToastAlert');
+      await element.updateComplete;
     });
 
     teardown(() => {
@@ -66,7 +69,7 @@
     });
 
     test('does not show auth error on 403 by default', async () => {
-      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const showAuthErrorStub = sinon.stub(element, 'showAuthErrorAlert');
       const responseText = Promise.resolve('server says no.');
       element.dispatchEvent(
         new CustomEvent('server-error', {
@@ -87,7 +90,7 @@
     });
 
     test('show auth required for 403 with auth error and not authed before', async () => {
-      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const showAuthErrorStub = sinon.stub(element, 'showAuthErrorAlert');
       const responseText = Promise.resolve('Authentication required\n');
       getLoggedInStub.returns(Promise.resolve(true));
       element.dispatchEvent(
@@ -132,7 +135,7 @@
     });
 
     test('show logged in error', () => {
-      const spy = sinon.spy(element, '_showAuthErrorAlert');
+      const spy = sinon.spy(element, 'showAuthErrorAlert');
       element.dispatchEvent(
         new CustomEvent('show-auth-required', {
           composed: true,
@@ -148,7 +151,7 @@
     });
 
     test('show normal Error', async () => {
-      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
+      const showErrorSpy = sinon.spy(element, 'showErrorDialog');
       const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
       element.dispatchEvent(
         new CustomEvent('server-error', {
@@ -219,10 +222,7 @@
         })
       );
       await flush();
-      assert.equal(
-        element.$.errorDialog.text,
-        'Error 500: 500\nTrace Id: xxxx'
-      );
+      assert.equal(element.errorDialog.text, 'Error 500: 500\nTrace Id: xxxx');
     });
 
     test('suppress TOO_MANY_FILES error', async () => {
@@ -259,31 +259,29 @@
       );
     });
 
-    test('_canOverride alerts', () => {
+    test('canOverride alerts', () => {
+      assert.isFalse(element.canOverride(undefined, __testOnly_ErrorType.AUTH));
       assert.isFalse(
-        element._canOverride(undefined, __testOnly_ErrorType.AUTH)
-      );
-      assert.isFalse(
-        element._canOverride(undefined, __testOnly_ErrorType.NETWORK)
+        element.canOverride(undefined, __testOnly_ErrorType.NETWORK)
       );
       assert.isTrue(
-        element._canOverride(undefined, __testOnly_ErrorType.GENERIC)
+        element.canOverride(undefined, __testOnly_ErrorType.GENERIC)
       );
-      assert.isTrue(element._canOverride(undefined, undefined));
+      assert.isTrue(element.canOverride(undefined, undefined));
 
       assert.isTrue(
-        element._canOverride(__testOnly_ErrorType.NETWORK, undefined)
+        element.canOverride(__testOnly_ErrorType.NETWORK, undefined)
       );
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH, undefined));
+      assert.isTrue(element.canOverride(__testOnly_ErrorType.AUTH, undefined));
       assert.isFalse(
-        element._canOverride(
+        element.canOverride(
           __testOnly_ErrorType.NETWORK,
           __testOnly_ErrorType.AUTH
         )
       );
 
       assert.isTrue(
-        element._canOverride(
+        element.canOverride(
           __testOnly_ErrorType.AUTH,
           __testOnly_ErrorType.NETWORK
         )
@@ -336,7 +334,7 @@
       assert.include(toast.shadowRoot.textContent, 'Refresh credentials');
 
       // noInteractionOverlay
-      const noInteractionOverlay = element.$.noInteractionOverlay;
+      const noInteractionOverlay = element.noInteractionOverlay;
       assert.isOk(noInteractionOverlay);
       const noInteractionOverlayCloseSpy = sinon.spy(
         noInteractionOverlay,
@@ -360,7 +358,7 @@
 
       clock.tick(1000);
       element.knownAccountId = 5 as AccountId;
-      element._checkSignedIn();
+      element.checkSignedIn();
       await flush();
 
       assert.isTrue(refreshStub.called);
@@ -525,15 +523,15 @@
     });
 
     test('checks stale credentials on visibility change', () => {
-      const refreshStub = sinon.stub(element, '_checkSignedIn');
+      const refreshStub = sinon.stub(element, 'checkSignedIn');
       sinon.stub(Date, 'now').returns(999999);
-      element._lastCredentialCheck = 0;
+      element.lastCredentialCheck = 0;
 
       document.dispatchEvent(new CustomEvent('visibilitychange'));
 
       // Since there is no known account, it should not test credentials.
       assert.isFalse(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 0);
+      assert.equal(element.lastCredentialCheck, 0);
 
       element.knownAccountId = 123 as AccountId;
 
@@ -541,7 +539,7 @@
 
       // Should test credentials, since there is a known account.
       assert.isTrue(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 999999);
+      assert.equal(element.lastCredentialCheck, 999999);
     });
 
     test('refreshes with same credentials', async () => {
@@ -549,16 +547,16 @@
         ...createAccountDetailWithId(1234),
       });
       stubRestApi('getAccount').returns(accountPromise);
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const requestCheckStub = sinon.stub(element, 'requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(
         element,
         'handleCredentialRefreshed'
       );
-      const reloadStub = sinon.stub(element, '_reloadPage');
+      const reloadStub = sinon.stub(element, 'reloadPage');
 
       element.knownAccountId = 1234 as AccountId;
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
+      element.refreshingCredentials = true;
+      element.checkSignedIn();
 
       await flush();
       assert.isFalse(requestCheckStub.called);
@@ -567,15 +565,15 @@
     });
 
     test('_showAlert hides existing alerts', () => {
-      element._alertElement = element._createToastAlert();
+      element.alertElement = element.createToastAlert();
       // const hideStub = sinon.stub(element, 'hideAlert');
       // element._showAlert('');
       // assert.isTrue(hideStub.calledOnce);
     });
 
     test('show-error', async () => {
-      const openStub = sinon.stub(element.$.errorOverlay, 'open');
-      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
+      const openStub = sinon.stub(element.errorOverlay, 'open');
+      const closeStub = sinon.stub(element.errorOverlay, 'close');
       const reportStub = stubReporting('reportErrorDialog');
 
       const message = 'test message';
@@ -590,9 +588,9 @@
 
       assert.isTrue(openStub.called);
       assert.isTrue(reportStub.called);
-      assert.equal(element.$.errorDialog.text, message);
+      assert.equal(element.errorDialog.text, message);
 
-      element.$.errorDialog.dispatchEvent(
+      element.errorDialog.dispatchEvent(
         new CustomEvent('dismiss', {
           composed: true,
           bubbles: true,
@@ -608,16 +606,16 @@
         ...createAccountDetailWithId(1234),
       });
       stubRestApi('getAccount').returns(accountPromise);
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const requestCheckStub = sinon.stub(element, 'requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(
         element,
         'handleCredentialRefreshed'
       );
-      const reloadStub = sinon.stub(element, '_reloadPage');
+      const reloadStub = sinon.stub(element, 'reloadPage');
 
       element.knownAccountId = 4321 as AccountId; // Different from 1234
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
+      element.refreshingCredentials = true;
+      element.checkSignedIn();
 
       await flush();
 
@@ -629,10 +627,13 @@
 
   suite('when not authed', () => {
     let toastSpy: sinon.SinonSpy;
-    setup(() => {
+    setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      toastSpy = sinon.spy(element, '_createToastAlert');
+      element = await fixture<GrErrorManager>(
+        html`<gr-error-manager></gr-error-manager>`
+      );
+      toastSpy = sinon.spy(element, 'createToastAlert');
+      await element.updateComplete;
     });
 
     teardown(() => {
@@ -642,15 +643,15 @@
     });
 
     test('refresh loop continues on credential fail', async () => {
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const requestCheckStub = sinon.stub(element, 'requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(
         element,
         'handleCredentialRefreshed'
       );
-      const reloadStub = sinon.stub(element, '_reloadPage');
+      const reloadStub = sinon.stub(element, 'reloadPage');
 
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
+      element.refreshingCredentials = true;
+      element.checkSignedIn();
 
       await flush();
       assert.isTrue(requestCheckStub.called);
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 70b1041..e20d73a 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -70,11 +70,9 @@
         :host {
           display: block;
           max-height: 100vh;
+          min-width: 60vw;
           overflow-y: auto;
         }
-        header {
-          padding: var(--spacing-l);
-        }
         main {
           display: flex;
           padding: 0 var(--spacing-xxl) var(--spacing-xxl);
@@ -83,39 +81,35 @@
           flex: 50%;
         }
         header {
+          padding: var(--spacing-l) var(--spacing-xxl);
           align-items: center;
           border-bottom: 1px solid var(--border-color);
           display: flex;
           justify-content: space-between;
         }
         table caption {
-          font-weight: var(--font-weight-bold);
           padding-top: var(--spacing-l);
           text-align: left;
         }
-        tr {
-          height: 32px;
-        }
         td {
           padding: var(--spacing-xs) 0;
+          vertical-align: middle;
+          width: 200px;
         }
         td:first-child,
         th:first-child {
           padding-right: var(--spacing-m);
           text-align: right;
-          width: 160px;
-          color: var(--deemphasized-text-color);
         }
-        td:second-child {
-          min-width: 200px;
+        td:last-child,
+        th:last-child {
+          text-align: left;
+        }
+        td:last-child {
+          color: var(--deemphasized-text-color);
         }
         th {
           color: var(--deemphasized-text-color);
-          text-align: left;
-        }
-        .header {
-          font-weight: var(--font-weight-bold);
-          padding-top: var(--spacing-l);
         }
         .modifier {
           font-weight: var(--font-weight-normal);
@@ -125,8 +119,9 @@
   }
 
   override render() {
-    return html`<header>
-        <h3 class="heading-3">Keyboard shortcuts</h3>
+    return html`
+      <header>
+        <h3 class="heading-2">Keyboard shortcuts</h3>
         <gr-button link="" @click=${this.handleCloseTap}>Close</gr-button>
       </header>
       <main>
@@ -137,28 +132,29 @@
           ${this._right?.map(section => this.renderSection(section))}
         </div>
       </main>
-      <footer></footer>`;
+      <footer></footer>
+    `;
   }
 
   private renderSection(section: SectionShortcut) {
     return html`<table>
-      <caption>
+      <caption class="heading-3">
         ${section.section}
       </caption>
       <thead>
         <tr>
-          <th>Key</th>
-          <th>Action</th>
+          <th><strong>Action</strong></th>
+          <th><strong>Key</strong></th>
         </tr>
       </thead>
       <tbody>
         ${section.shortcuts?.map(
           shortcut => html`<tr>
+            <td>${shortcut.text}</td>
             <td>
               <gr-key-binding-display .binding=${shortcut.binding}>
               </gr-key-binding-display>
             </td>
-            <td>${shortcut.text}</td>
           </tr>`
         )}
       </tbody>
@@ -215,14 +211,14 @@
     }
 
     if (directory.has(ShortcutSection.REPLY_DIALOG)) {
-      right.push({
+      left.push({
         section: ShortcutSection.REPLY_DIALOG,
         shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
       });
     }
 
     if (directory.has(ShortcutSection.FILE_LIST)) {
-      right.push({
+      left.push({
         section: ShortcutSection.FILE_LIST,
         shortcuts: directory.get(ShortcutSection.FILE_LIST),
       });
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
index 7fc52f5..8ec43ca 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
@@ -80,28 +80,28 @@
       assert.isEmpty(element._left);
     });
 
-    test('reply dialog goes on right', () => {
+    test('reply dialog goes on left', () => {
       const sectionView = [{binding: [], text: 'reply dialog shortcuts'}];
       update(new Map([[ShortcutSection.REPLY_DIALOG, sectionView]]));
-      assert.deepEqual(element._right, [
+      assert.deepEqual(element._left, [
         {
           section: ShortcutSection.REPLY_DIALOG,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element._right);
     });
 
-    test('file list goes on right', () => {
+    test('file list goes on left', () => {
       const sectionView = [{binding: [], text: 'file list shortcuts'}];
       update(new Map([[ShortcutSection.FILE_LIST, sectionView]]));
-      assert.deepEqual(element._right, [
+      assert.deepEqual(element._left, [
         {
           section: ShortcutSection.FILE_LIST,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element._right);
     });
 
     test('diffs go on right', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 5cc3737..e736928 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -358,7 +358,7 @@
   override render() {
     return html`
   <nav>
-    <a href="${`//${window.location.host}${getBaseUrl()}/`}" class="bigTitle">
+    <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
       <gr-endpoint-decorator name="header-title">
         <span class="titleText"></span>
       </gr-endpoint-decorator>
@@ -398,14 +398,14 @@
 
   private renderLinkGroup(linkGroup: MainHeaderLinkGroup) {
     return html`
-      <li class="${linkGroup.class ?? ''}">
+      <li class=${linkGroup.class ?? ''}>
         <gr-dropdown
           link
           down-arrow
           .items=${linkGroup.links}
           horizontal-align="left"
         >
-          <span class="linksTitle" id="${linkGroup.title}">
+          <span class="linksTitle" id=${linkGroup.title}>
             ${linkGroup.title}
           </span>
         </gr-dropdown>
@@ -418,7 +418,7 @@
 
     return html`
       <a
-        href="${this.feedbackURL}"
+        href=${this.feedbackURL}
         title="File a bug"
         aria-label="File a bug"
         target="_blank"
@@ -439,12 +439,12 @@
             this.onMobileSearchTap(e);
           }}
           role="button"
-          aria-label="${this.mobileSearchHidden
+          aria-label=${this.mobileSearchHidden
             ? 'Show Searchbar'
-            : 'Hide Searchbar'}"
+            : 'Hide Searchbar'}
         ></iron-icon>
         ${this.renderRegister()}
-        <a class="loginButton" href="${this.loginUrl}">Sign in</a>
+        <a class="loginButton" href=${this.loginUrl}>Sign in</a>
         <a
           class="settingsButton"
           href="${getBaseUrl()}/settings/"
@@ -464,7 +464,7 @@
 
     return html`
       <div class="registerDiv">
-        <a class="registerButton" href="${this.registerURL}">
+        <a class="registerButton" href=${this.registerURL}>
           ${this.registerText}
         </a>
       </div>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 386591b..4453a7e 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -535,14 +535,14 @@
    * Navigate to a search query
    */
   navigateToSearchQuery(query: string, offset?: number) {
-    return this._navigate(this.getUrlForSearchQuery(query, offset));
+    this._navigate(this.getUrlForSearchQuery(query, offset));
   },
 
   /**
    * Navigate to the user's dashboard
    */
   navigateToUserDashboard() {
-    return this._navigate(this.getUrlForUserDashboard('self'));
+    this._navigate(this.getUrlForUserDashboard('self'));
   },
 
   /**
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
deleted file mode 100644
index 40a06bc..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {GerritNav} from './gr-navigation.js';
-
-suite('gr-navigation tests', () => {
-  test('invalid patch ranges throw exceptions', () => {
-    assert.throw(() => GerritNav.getUrlForChange('123', {basePatchNum: 12}));
-    assert.throw(() => GerritNav.getUrlForDiff('123', 'x.c', undefined, 12));
-  });
-
-  suite('_getUserDashboard', () => {
-    const sections = [
-      {name: 'section 1', query: 'query 1'},
-      {name: 'section 2', query: 'query 2 for ${user}'},
-      {name: 'section 3', query: 'self only query', selfOnly: true},
-      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
-    ];
-
-    test('dashboard for self', () => {
-      const dashboard =
-           GerritNav.getUserDashboard('self', sections, 'title');
-      assert.deepEqual(
-          dashboard,
-          {
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2 for self'},
-              {
-                name: 'section 3',
-                query: 'self only query',
-                selfOnly: true,
-              }, {
-                name: 'section 4',
-                query: 'query 4',
-                suffixForDashboard: 'suffix',
-              },
-            ],
-          });
-    });
-
-    test('dashboard for other user', () => {
-      const dashboard =
-           GerritNav.getUserDashboard('user', sections, 'title');
-      assert.deepEqual(
-          dashboard,
-          {
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2 for user'},
-              {
-                name: 'section 4',
-                query: 'query 4',
-                suffixForDashboard: 'suffix',
-              },
-            ],
-          });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.ts
new file mode 100644
index 0000000..03266486
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.ts
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {BasePatchSetNum, NumericChangeId} from '../../../types/common';
+import {GerritNav} from './gr-navigation';
+
+suite('gr-navigation tests', () => {
+  test('invalid patch ranges throw exceptions', () => {
+    assert.throw(() =>
+      GerritNav.getUrlForChange(
+        {...createChange(), _number: 123 as NumericChangeId},
+        {basePatchNum: 12 as BasePatchSetNum}
+      )
+    );
+    assert.throw(() =>
+      GerritNav.getUrlForDiff(
+        {...createChange(), _number: 123 as NumericChangeId},
+        'x.c',
+        undefined,
+        12 as BasePatchSetNum
+      )
+    );
+  });
+
+  suite('_getUserDashboard', () => {
+    const sections = [
+      {name: 'section 1', query: 'query 1'},
+      {name: 'section 2', query: 'query 2 for ${user}'},
+      {name: 'section 3', query: 'self only query', selfOnly: true},
+      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+    ];
+
+    test('dashboard for self', () => {
+      const dashboard = GerritNav.getUserDashboard('self', sections, 'title');
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'query 2 for self'},
+          {
+            name: 'section 3',
+            query: 'self only query',
+            selfOnly: true,
+          },
+          {
+            name: 'section 4',
+            query: 'query 4',
+            suffixForDashboard: 'suffix',
+          },
+        ],
+      });
+    });
+
+    test('dashboard for other user', () => {
+      const dashboard = GerritNav.getUserDashboard('user', sections, 'title');
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'query 2 for user'},
+          {
+            name: 'section 4',
+            query: 'query 4',
+            suffixForDashboard: 'suffix',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index fa737c1..8c17285 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -14,13 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
   page,
   PageContext,
   PageNextCallback,
 } from '../../../utils/page-wrapper-utils';
-import {htmlTemplate} from './gr-router_html';
 import {
   DashboardSection,
   GeneratedWebLink,
@@ -46,7 +44,6 @@
 } from '../gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {convertToPatchSetNum} from '../../../utils/patch-set-util';
-import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
@@ -103,7 +100,7 @@
   // Redirects /groups/self to /settings/#Groups for GWT compatibility
   GROUP_SELF: /^\/groups\/self/,
 
-  // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
+  // Matches /admin/groups/[uuid-]<group>,info (backwards compat with gwtui)
   // Redirects to /admin/groups/[uuid-]<group>
   GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
 
@@ -288,21 +285,13 @@
   basePatchNum?: BasePatchSetNum;
 }
 
-@customElement('gr-router')
-export class GrRouter extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
+export class GrRouter {
   readonly _app = app;
 
-  @property({type: Boolean})
   _isRedirecting?: boolean;
 
   // This variable is to differentiate between internal navigation (false)
   // and for first navigation in app after loaded from server (true).
-  @property({type: Boolean})
   _isInitialLoad = true;
 
   private readonly reporting = getAppContext().reportingService;
@@ -315,19 +304,19 @@
     if (!this._app) {
       return;
     }
-    this._startRouter();
+    this.startRouter();
   }
 
-  _setParams(params: AppElementParams | GenerateUrlParameters) {
+  setParams(params: AppElementParams | GenerateUrlParameters) {
     this.routerModel.updateState({
       view: params.view,
       changeNum: 'changeNum' in params ? params.changeNum : undefined,
       patchNum: 'patchNum' in params ? params.patchNum ?? undefined : undefined,
     });
-    this._appElement().params = params;
+    this.appElement().params = params;
   }
 
-  _appElement(): AppElement {
+  private appElement(): AppElement {
     // In Polymer2 you have to reach through the shadow root of the app
     // element. This obviously breaks encapsulation.
     // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
@@ -342,34 +331,34 @@
         .shadowRoot!.getElementById('app-element')!) as AppElement;
   }
 
-  _redirect(url: string) {
+  redirect(url: string) {
     this._isRedirecting = true;
     page.redirect(url);
   }
 
-  _generateUrl(params: GenerateUrlParameters) {
+  generateUrl(params: GenerateUrlParameters) {
     const base = getBaseUrl();
     let url = '';
 
     if (params.view === GerritView.SEARCH) {
-      url = this._generateSearchUrl(params);
+      url = this.generateSearchUrl(params);
     } else if (params.view === GerritView.CHANGE) {
-      url = this._generateChangeUrl(params);
+      url = this.generateChangeUrl(params);
     } else if (params.view === GerritView.DASHBOARD) {
-      url = this._generateDashboardUrl(params);
+      url = this.generateDashboardUrl(params);
     } else if (
       params.view === GerritView.DIFF ||
       params.view === GerritView.EDIT
     ) {
-      url = this._generateDiffOrEditUrl(params);
+      url = this.generateDiffOrEditUrl(params);
     } else if (params.view === GerritView.GROUP) {
-      url = this._generateGroupUrl(params);
+      url = this.generateGroupUrl(params);
     } else if (params.view === GerritView.REPO) {
-      url = this._generateRepoUrl(params);
+      url = this.generateRepoUrl(params);
     } else if (params.view === GerritView.ROOT) {
       url = '/';
     } else if (params.view === GerritView.SETTINGS) {
-      url = this._generateSettingsUrl();
+      url = this.generateSettingsUrl();
     } else {
       assertNever(params, "Can't generate");
     }
@@ -377,33 +366,33 @@
     return base + url;
   }
 
-  _generateWeblinks(
+  generateWeblinks(
     params: GenerateWebLinksParameters
   ): GeneratedWebLink[] | GeneratedWebLink {
     switch (params.type) {
       case WeblinkType.EDIT:
-        return this._getEditWebLinks(params);
+        return this.getEditWebLinks(params);
       case WeblinkType.FILE:
-        return this._getFileWebLinks(params);
+        return this.getFileWebLinks(params);
       case WeblinkType.CHANGE:
-        return this._getChangeWeblinks(params);
+        return this.getChangeWeblinks(params);
       case WeblinkType.PATCHSET:
-        return this._getPatchSetWeblink(params);
+        return this.getPatchSetWeblink(params);
       case WeblinkType.RESOLVE_CONFLICTS:
-        return this._getResolveConflictsWeblinks(params);
+        return this.getResolveConflictsWeblinks(params);
       default:
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         assertNever(params, `Unsupported weblink ${(params as any).type}!`);
     }
   }
 
-  _getPatchSetWeblink(
+  private getPatchSetWeblink(
     params: GenerateWebLinksPatchsetParameters
   ): GeneratedWebLink {
     const {commit, options} = params;
     const {weblinks, config} = options || {};
     const name = commit && commit.slice(0, 7);
-    const weblink = this._getBrowseCommitWeblink(weblinks, config);
+    const weblink = this.getBrowseCommitWeblink(weblinks, config);
     if (!weblink || !weblink.url) {
       return {name};
     } else {
@@ -411,13 +400,13 @@
     }
   }
 
-  _getResolveConflictsWeblinks(
+  private getResolveConflictsWeblinks(
     params: GenerateWebLinksResolveConflictsParameters
   ): GeneratedWebLink[] {
     return params.options?.weblinks ?? [];
   }
 
-  _firstCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
+  firstCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
     // This is an ordered allowed list of web link types that provide direct
     // links to the commit in the url property.
     const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
@@ -432,7 +421,7 @@
     return null;
   }
 
-  _getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
+  getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
     if (!weblinks) {
       return null;
     }
@@ -443,7 +432,7 @@
       weblink = weblinks.find(weblink => weblink.name === primaryWeblinkName);
     }
     if (!weblink) {
-      weblink = this._firstCodeBrowserWeblink(weblinks);
+      weblink = this.firstCodeBrowserWeblink(weblinks);
     }
     if (!weblink) {
       return null;
@@ -451,13 +440,13 @@
     return weblink;
   }
 
-  _getChangeWeblinks(
+  getChangeWeblinks(
     params: GenerateWebLinksChangeParameters
   ): GeneratedWebLink[] {
     const weblinks = params.options?.weblinks;
     const config = params.options?.config;
     if (!weblinks || !weblinks.length) return [];
-    const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
+    const commitWeblink = this.getBrowseCommitWeblink(weblinks, config);
     return weblinks.filter(
       weblink =>
         !commitWeblink ||
@@ -466,15 +455,19 @@
     );
   }
 
-  _getEditWebLinks(params: GenerateWebLinksEditParameters): GeneratedWebLink[] {
+  private getEditWebLinks(
+    params: GenerateWebLinksEditParameters
+  ): GeneratedWebLink[] {
     return params.options?.weblinks ?? [];
   }
 
-  _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
+  private getFileWebLinks(
+    params: GenerateWebLinksFileParameters
+  ): GeneratedWebLink[] {
     return params.options?.weblinks ?? [];
   }
 
-  _generateSearchUrl(params: GenerateUrlSearchViewParameters) {
+  private generateSearchUrl(params: GenerateUrlSearchViewParameters) {
     let offsetExpr = '';
     if (params.offset && params.offset > 0) {
       offsetExpr = `,${params.offset}`;
@@ -529,8 +522,8 @@
     return '/q/' + operators.join('+') + offsetExpr;
   }
 
-  _generateChangeUrl(params: GenerateUrlChangeViewParameters) {
-    let range = this._getPatchRangeExpression(params);
+  private generateChangeUrl(params: GenerateUrlChangeViewParameters) {
+    let range = this.getPatchRangeExpression(params);
     if (range.length) {
       range = '/' + range;
     }
@@ -559,11 +552,11 @@
     }
   }
 
-  _generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
+  private generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
     const repoName = params.repo || params.project || undefined;
     if (params.sections) {
       // Custom dashboard.
-      const queryParams = this._sectionsToEncodedParams(
+      const queryParams = this.sectionsToEncodedParams(
         params.sections,
         repoName
       );
@@ -582,7 +575,10 @@
     }
   }
 
-  _sectionsToEncodedParams(sections: DashboardSection[], repoName?: RepoName) {
+  private sectionsToEncodedParams(
+    sections: DashboardSection[],
+    repoName?: RepoName
+  ) {
     return sections.map(section => {
       // If there is a repo name provided, make sure to substitute it into the
       // ${repo} (or legacy ${project}) query tokens.
@@ -593,10 +589,10 @@
     });
   }
 
-  _generateDiffOrEditUrl(
+  private generateDiffOrEditUrl(
     params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
   ) {
-    let range = this._getPatchRangeExpression(params);
+    let range = this.getPatchRangeExpression(params);
     if (range.length) {
       range = '/' + range;
     }
@@ -627,7 +623,7 @@
     }
   }
 
-  _generateGroupUrl(params: GenerateUrlGroupViewParameters) {
+  private generateGroupUrl(params: GenerateUrlGroupViewParameters) {
     let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
     if (params.detail === GroupDetailView.MEMBERS) {
       url += ',members';
@@ -637,7 +633,7 @@
     return url;
   }
 
-  _generateRepoUrl(params: GenerateUrlRepoViewParameters) {
+  private generateRepoUrl(params: GenerateUrlRepoViewParameters) {
     let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
     if (params.detail === RepoDetailView.GENERAL) {
       url += ',general';
@@ -655,7 +651,7 @@
     return url;
   }
 
-  _generateSettingsUrl() {
+  private generateSettingsUrl() {
     return '/settings';
   }
 
@@ -664,7 +660,7 @@
    * `basePatchNum` or both, return a string representation of that range. If
    * no range is indicated in the params, the empty string is returned.
    */
-  _getPatchRangeExpression(params: PatchRangeParams) {
+  getPatchRangeExpression(params: PatchRangeParams) {
     let range = '';
     if (params.patchNum) {
       range = `${params.patchNum}`;
@@ -680,7 +676,7 @@
    * modified to fit the proper schema.
    *
    */
-  _normalizePatchRangeParams(params: PatchRangeParams) {
+  normalizePatchRangeParams(params: PatchRangeParams) {
     if (params.basePatchNum === undefined) {
       return false;
     }
@@ -705,7 +701,7 @@
    * Redirect the user to login using the given return-URL for redirection
    * after authentication success.
    */
-  _redirectToLogin(returnUrl: string) {
+  redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
     page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
   }
@@ -717,11 +713,11 @@
    *
    * @return Everything after the first '#' ("a#b#c" -> "b#c").
    */
-  _getHashFromCanonicalPath(canonicalPath: string) {
+  getHashFromCanonicalPath(canonicalPath: string) {
     return canonicalPath.split('#').slice(1).join('#');
   }
 
-  _parseLineAddress(hash: string) {
+  parseLineAddress(hash: string) {
     const match = hash.match(LINE_ADDRESS_PATTERN);
     if (!match) {
       return null;
@@ -740,26 +736,26 @@
    * @return A promise yielding the original route data
    * (if it resolves).
    */
-  _redirectIfNotLoggedIn(data: PageContext) {
+  redirectIfNotLoggedIn(data: PageContext) {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         return Promise.resolve();
       } else {
-        this._redirectToLogin(data.canonicalPath);
+        this.redirectToLogin(data.canonicalPath);
         return Promise.reject(new Error());
       }
     });
   }
 
   /**  Page.js middleware that warms the REST API's logged-in cache line. */
-  _loadUserMiddleware(_: PageContext, next: PageNextCallback) {
+  private loadUserMiddleware(_: PageContext, next: PageNextCallback) {
     this.restApiService.getLoggedIn().then(() => {
       next();
     });
   }
 
   /**  Page.js middleware that try parse the querystring into queryMap. */
-  _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
+  private queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
     (ctx as PageContextWithQueryMap).queryMap = this.createQueryMap(ctx);
     next();
   }
@@ -773,7 +769,7 @@
         this.reporting.reportExecution(Execution.REACHABLE_CODE, {
           id: 'noURLSearchParams',
         });
-        return new Map(this._parseQueryString(ctx.querystring));
+        return new Map(this.parseQueryString(ctx.querystring));
       }
     }
     return new Map<string, string>();
@@ -790,36 +786,31 @@
    * @param authRedirect If true, then auth is checked before
    * executing the handler. If the user is not logged in, it will redirect
    * to the login flow and the handler will not be executed. The login
-   * redirect specifies the matched URL to be used after successfull auth.
+   * redirect specifies the matched URL to be used after successful auth.
    */
-  _mapRoute(
+  mapRoute(
     pattern: string | RegExp,
-    handlerName: keyof GrRouter,
+    handlerName: string,
+    handler: (ctx: PageContextWithQueryMap) => void,
     authRedirect?: boolean
   ) {
-    if (!this[handlerName]) {
-      this.reporting.error(
-        new Error(`Attempted to map route to unknown method: ${handlerName}`)
-      );
-      return;
-    }
     page(
       pattern,
-      (ctx, next) => this._loadUserMiddleware(ctx, next),
-      (ctx, next) => this._queryStringMiddleware(ctx, next),
+      (ctx, next) => this.loadUserMiddleware(ctx, next),
+      (ctx, next) => this.queryStringMiddleware(ctx, next),
       ctx => {
         this.reporting.locationChanged(handlerName);
         const promise = authRedirect
-          ? this._redirectIfNotLoggedIn(ctx)
+          ? this.redirectIfNotLoggedIn(ctx)
           : Promise.resolve();
         promise.then(() => {
-          this[handlerName](ctx as PageContextWithQueryMap);
+          handler(ctx as PageContextWithQueryMap);
         });
       }
     );
   }
 
-  _startRouter() {
+  startRouter() {
     const base = getBaseUrl();
     if (base) {
       page.base(base);
@@ -833,8 +824,8 @@
           page.show(url);
         }
       },
-      params => this._generateUrl(params),
-      params => this._generateWeblinks(params),
+      params => this.generateUrl(params),
+      params => this.generateWeblinks(params),
       x => x
     );
 
@@ -857,7 +848,7 @@
           const usp = searchParams.get('usp');
           this.reporting.reportLifeCycle(LifeCycle.USER_REFERRED_FROM, {usp});
           searchParams.delete('usp');
-          this._redirect(toPath(pathname, searchParams));
+          this.redirect(toPath(pathname, searchParams));
           return;
         }
       }
@@ -872,7 +863,7 @@
         // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
         // This is needed to allow plugins to add basic #/x/ screen links to
         // any location.
-        this._redirect(ctx.hash);
+        this.redirect(ctx.hash);
         return;
       }
 
@@ -883,7 +874,7 @@
           hash: window.location.hash,
           pathname: window.location.pathname,
         };
-        this.dispatchEvent(
+        window.dispatchEvent(
           new CustomEvent('location-change', {
             detail,
             composed: true,
@@ -894,225 +885,350 @@
       next();
     });
 
-    this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
+    this.mapRoute(RoutePattern.ROOT, 'handleRootRoute', ctx =>
+      this.handleRootRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
+    this.mapRoute(RoutePattern.DASHBOARD, 'handleDashboardRoute', ctx =>
+      this.handleDashboardRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.CUSTOM_DASHBOARD,
-      '_handleCustomDashboardRoute'
+      'handleCustomDashboardRoute',
+      ctx => this.handleCustomDashboardRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PROJECT_DASHBOARD,
-      '_handleProjectDashboardRoute'
+      'handleProjectDashboardRoute',
+      ctx => this.handleProjectDashboardRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.LEGACY_PROJECT_DASHBOARD,
-      '_handleLegacyProjectDashboardRoute'
+      'handleLegacyProjectDashboardRoute',
+      ctx => this.handleLegacyProjectDashboardRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
+    this.mapRoute(
+      RoutePattern.GROUP_INFO,
+      'handleGroupInfoRoute',
+      ctx => this.handleGroupInfoRoute(ctx),
+      true
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_AUDIT_LOG,
-      '_handleGroupAuditLogRoute',
+      'handleGroupAuditLogRoute',
+      ctx => this.handleGroupAuditLogRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_MEMBERS,
-      '_handleGroupMembersRoute',
+      'handleGroupMembersRoute',
+      ctx => this.handleGroupMembersRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_LIST_OFFSET,
-      '_handleGroupListOffsetRoute',
+      'handleGroupListOffsetRoute',
+      ctx => this.handleGroupListOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_LIST_FILTER_OFFSET,
-      '_handleGroupListFilterOffsetRoute',
+      'handleGroupListFilterOffsetRoute',
+      ctx => this.handleGroupListFilterOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_LIST_FILTER,
-      '_handleGroupListFilterRoute',
+      'handleGroupListFilterRoute',
+      ctx => this.handleGroupListFilterRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_SELF,
-      '_handleGroupSelfRedirectRoute',
+      'handleGroupSelfRedirectRoute',
+      ctx => this.handleGroupSelfRedirectRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
+    this.mapRoute(
+      RoutePattern.GROUP,
+      'handleGroupRoute',
+      ctx => this.handleGroupRoute(ctx),
+      true
+    );
 
-    this._mapRoute(RoutePattern.PROJECT_OLD, '_handleProjectsOldRoute');
+    this.mapRoute(RoutePattern.PROJECT_OLD, 'handleProjectsOldRoute', ctx =>
+      this.handleProjectsOldRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.REPO_COMMANDS,
-      '_handleRepoCommandsRoute',
+      'handleRepoCommandsRoute',
+      ctx => this.handleRepoCommandsRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.REPO_GENERAL, '_handleRepoGeneralRoute');
+    this.mapRoute(RoutePattern.REPO_GENERAL, 'handleRepoGeneralRoute', ctx =>
+      this.handleRepoGeneralRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.REPO_ACCESS, '_handleRepoAccessRoute');
+    this.mapRoute(RoutePattern.REPO_ACCESS, 'handleRepoAccessRoute', ctx =>
+      this.handleRepoAccessRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.REPO_DASHBOARDS, '_handleRepoDashboardsRoute');
+    this.mapRoute(
+      RoutePattern.REPO_DASHBOARDS,
+      'handleRepoDashboardsRoute',
+      ctx => this.handleRepoDashboardsRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.BRANCH_LIST_OFFSET,
-      '_handleBranchListOffsetRoute'
+      'handleBranchListOffsetRoute',
+      ctx => this.handleBranchListOffsetRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-      '_handleBranchListFilterOffsetRoute'
+      'handleBranchListFilterOffsetRoute',
+      ctx => this.handleBranchListFilterOffsetRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.BRANCH_LIST_FILTER,
-      '_handleBranchListFilterRoute'
+      'handleBranchListFilterRoute',
+      ctx => this.handleBranchListFilterRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.TAG_LIST_OFFSET, '_handleTagListOffsetRoute');
+    this.mapRoute(
+      RoutePattern.TAG_LIST_OFFSET,
+      'handleTagListOffsetRoute',
+      ctx => this.handleTagListOffsetRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.TAG_LIST_FILTER_OFFSET,
-      '_handleTagListFilterOffsetRoute'
+      'handleTagListFilterOffsetRoute',
+      ctx => this.handleTagListFilterOffsetRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.TAG_LIST_FILTER, '_handleTagListFilterRoute');
+    this.mapRoute(
+      RoutePattern.TAG_LIST_FILTER,
+      'handleTagListFilterRoute',
+      ctx => this.handleTagListFilterRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.LEGACY_CREATE_GROUP,
-      '_handleCreateGroupRoute',
+      'handleCreateGroupRoute',
+      ctx => this.handleCreateGroupRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.LEGACY_CREATE_PROJECT,
-      '_handleCreateProjectRoute',
+      'handleCreateProjectRoute',
+      ctx => this.handleCreateProjectRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.REPO_LIST_OFFSET, '_handleRepoListOffsetRoute');
+    this.mapRoute(
+      RoutePattern.REPO_LIST_OFFSET,
+      'handleRepoListOffsetRoute',
+      ctx => this.handleRepoListOffsetRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.REPO_LIST_FILTER_OFFSET,
-      '_handleRepoListFilterOffsetRoute'
+      'handleRepoListFilterOffsetRoute',
+      ctx => this.handleRepoListFilterOffsetRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.REPO_LIST_FILTER, '_handleRepoListFilterRoute');
+    this.mapRoute(
+      RoutePattern.REPO_LIST_FILTER,
+      'handleRepoListFilterRoute',
+      ctx => this.handleRepoListFilterRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
+    this.mapRoute(RoutePattern.REPO, 'handleRepoRoute', ctx =>
+      this.handleRepoRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+    this.mapRoute(RoutePattern.PLUGINS, 'handlePassThroughRoute', () =>
+      this.handlePassThroughRoute()
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PLUGIN_LIST_OFFSET,
-      '_handlePluginListOffsetRoute',
+      'handlePluginListOffsetRoute',
+      ctx => this.handlePluginListOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-      '_handlePluginListFilterOffsetRoute',
+      'handlePluginListFilterOffsetRoute',
+      ctx => this.handlePluginListFilterOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PLUGIN_LIST_FILTER,
-      '_handlePluginListFilterRoute',
+      'handlePluginListFilterRoute',
+      ctx => this.handlePluginListFilterRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
+    this.mapRoute(
+      RoutePattern.PLUGIN_LIST,
+      'handlePluginListRoute',
+      ctx => this.handlePluginListRoute(ctx),
+      true
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.QUERY_LEGACY_SUFFIX,
-      '_handleQueryLegacySuffixRoute'
+      'handleQueryLegacySuffixRoute',
+      ctx => this.handleQueryLegacySuffixRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
+    this.mapRoute(RoutePattern.QUERY, 'handleQueryRoute', ctx =>
+      this.handleQueryRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.CHANGE_ID_QUERY, '_handleChangeIdQueryRoute');
+    this.mapRoute(
+      RoutePattern.CHANGE_ID_QUERY,
+      'handleChangeIdQueryRoute',
+      ctx => this.handleChangeIdQueryRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
+    this.mapRoute(
+      RoutePattern.DIFF_LEGACY_LINENUM,
+      'handleLegacyLinenum',
+      ctx => this.handleLegacyLinenum(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.CHANGE_NUMBER_LEGACY,
-      '_handleChangeNumberLegacyRoute'
+      'handleChangeNumberLegacyRoute',
+      ctx => this.handleChangeNumberLegacyRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
+    this.mapRoute(
+      RoutePattern.DIFF_EDIT,
+      'handleDiffEditRoute',
+      ctx => this.handleDiffEditRoute(ctx),
+      true
+    );
 
-    this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
+    this.mapRoute(
+      RoutePattern.CHANGE_EDIT,
+      'handleChangeEditRoute',
+      ctx => this.handleChangeEditRoute(ctx),
+      true
+    );
 
-    this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');
+    this.mapRoute(RoutePattern.COMMENT, 'handleCommentRoute', ctx =>
+      this.handleCommentRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.COMMENTS_TAB, '_handleCommentsRoute');
+    this.mapRoute(RoutePattern.COMMENTS_TAB, 'handleCommentsRoute', ctx =>
+      this.handleCommentsRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
+    this.mapRoute(RoutePattern.DIFF, 'handleDiffRoute', ctx =>
+      this.handleDiffRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
+    this.mapRoute(RoutePattern.CHANGE, 'handleChangeRoute', ctx =>
+      this.handleChangeRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
+    this.mapRoute(RoutePattern.CHANGE_LEGACY, 'handleChangeLegacyRoute', ctx =>
+      this.handleChangeLegacyRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
+    this.mapRoute(
+      RoutePattern.AGREEMENTS,
+      'handleAgreementsRoute',
+      () => this.handleAgreementsRoute(),
+      true
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.NEW_AGREEMENTS,
-      '_handleNewAgreementsRoute',
+      'handleNewAgreementsRoute',
+      ctx => this.handleNewAgreementsRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.SETTINGS_LEGACY,
-      '_handleSettingsLegacyRoute',
+      'handleSettingsLegacyRoute',
+      ctx => this.handleSettingsLegacyRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
-
-    this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
-
-    this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
-
-    this._mapRoute(
-      RoutePattern.IMPROPERLY_ENCODED_PLUS,
-      '_handleImproperlyEncodedPlusRoute'
+    this.mapRoute(
+      RoutePattern.SETTINGS,
+      'handleSettingsRoute',
+      ctx => this.handleSettingsRoute(ctx),
+      true
     );
 
-    this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
+    this.mapRoute(RoutePattern.REGISTER, 'handleRegisterRoute', ctx =>
+      this.handleRegisterRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(RoutePattern.LOG_IN_OR_OUT, 'handlePassThroughRoute', () =>
+      this.handlePassThroughRoute()
+    );
+
+    this.mapRoute(
+      RoutePattern.IMPROPERLY_ENCODED_PLUS,
+      'handleImproperlyEncodedPlusRoute',
+      ctx => this.handleImproperlyEncodedPlusRoute(ctx)
+    );
+
+    this.mapRoute(RoutePattern.PLUGIN_SCREEN, 'handlePluginScreen', ctx =>
+      this.handlePluginScreen(ctx)
+    );
+
+    this.mapRoute(
       RoutePattern.DOCUMENTATION_SEARCH_FILTER,
-      '_handleDocumentationSearchRoute'
+      'handleDocumentationSearchRoute',
+      ctx => this.handleDocumentationSearchRoute(ctx)
     );
 
     // redirects /Documentation/q/* to /Documentation/q/filter:*
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.DOCUMENTATION_SEARCH,
-      '_handleDocumentationSearchRedirectRoute'
+      'handleDocumentationSearchRedirectRoute',
+      ctx => this.handleDocumentationSearchRedirectRoute(ctx)
     );
 
-    // Makes sure /Documentation/* links work (doin't return 404)
-    this._mapRoute(
+    // Makes sure /Documentation/* links work (don't return 404)
+    this.mapRoute(
       RoutePattern.DOCUMENTATION,
-      '_handleDocumentationRedirectRoute'
+      'handleDocumentationRedirectRoute',
+      ctx => this.handleDocumentationRedirectRoute(ctx)
     );
 
     // Note: this route should appear last so it only catches URLs unmatched
     // by other patterns.
-    this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
+    this.mapRoute(RoutePattern.DEFAULT, 'handleDefaultRoute', () =>
+      this.handleDefaultRoute()
+    );
 
     page.start();
   }
@@ -1121,13 +1237,13 @@
    * @return if handling the route involves asynchrony, then a
    * promise is returned. Otherwise, synchronous handling returns null.
    */
-  _handleRootRoute(data: PageContextWithQueryMap) {
+  handleRootRoute(data: PageContextWithQueryMap) {
     if (data.querystring.match(/^closeAfterLogin/)) {
       // Close child window on redirect after login.
       window.close();
       return null;
     }
-    let hash = this._getHashFromCanonicalPath(data.canonicalPath);
+    let hash = this.getHashFromCanonicalPath(data.canonicalPath);
     // For backward compatibility with GWT links.
     if (hash) {
       // In certain login flows the server may redirect to a hash without
@@ -1145,14 +1261,14 @@
       if (hash.startsWith('/VE/')) {
         newUrl = base + '/settings' + hash;
       }
-      this._redirect(newUrl);
+      this.redirect(newUrl);
       return null;
     }
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        this._redirect('/dashboard/self');
+        this.redirect('/dashboard/self');
       } else {
-        this._redirect('/q/status:open+-is:wip');
+        this.redirect('/q/status:open+-is:wip');
       }
     });
   }
@@ -1163,7 +1279,7 @@
    * @param qs The application/x-www-form-urlencoded string.
    * @return The decoded string.
    */
-  _decodeQueryString(qs: string) {
+  private decodeQueryString(qs: string) {
     return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
   }
 
@@ -1175,7 +1291,7 @@
    * @return An array of name/value pairs, where each
    * element is a 2-element array.
    */
-  _parseQueryString(qs: string): Array<QueryStringItem> {
+  parseQueryString(qs: string): Array<QueryStringItem> {
     qs = qs.replace(QUESTION_PATTERN, '');
     if (!qs) {
       return [];
@@ -1186,11 +1302,11 @@
       let name;
       let value;
       if (idx < 0) {
-        name = this._decodeQueryString(param);
+        name = this.decodeQueryString(param);
         value = '';
       } else {
-        name = this._decodeQueryString(param.substring(0, idx));
-        value = this._decodeQueryString(param.substring(idx + 1));
+        name = this.decodeQueryString(param.substring(0, idx));
+        value = this.decodeQueryString(param.substring(idx + 1));
       }
       if (name) {
         params.push([name, value]);
@@ -1202,19 +1318,19 @@
   /**
    * Handle dashboard routes. These may be user, or project dashboards.
    */
-  _handleDashboardRoute(data: PageContextWithQueryMap) {
+  handleDashboardRoute(data: PageContextWithQueryMap) {
     // User dashboard. We require viewing user to be logged in, else we
     // redirect to login for self dashboard or simple owner search for
     // other user dashboard.
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (!loggedIn) {
         if (data.params[0].toLowerCase() === 'self') {
-          this._redirectToLogin(data.canonicalPath);
+          this.redirectToLogin(data.canonicalPath);
         } else {
-          this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
+          this.redirect('/q/owner:' + encodeURIComponent(data.params[0]));
         }
       } else {
-        this._setParams({
+        this.setParams({
           view: GerritView.DASHBOARD,
           user: data.params[0],
         });
@@ -1228,11 +1344,11 @@
    * @param qs Optional query string associated with the route.
    * If not given, window.location.search is used. (Used by tests).
    */
-  _handleCustomDashboardRoute(
+  handleCustomDashboardRoute(
     _: PageContextWithQueryMap,
     qs: string = window.location.search
   ) {
-    const queryParams = this._parseQueryString(qs);
+    const queryParams = this.parseQueryString(qs);
     let title = 'Custom Dashboard';
     const titleParam = queryParams.find(
       elem => elem[0].toLowerCase() === 'title'
@@ -1266,7 +1382,7 @@
 
     if (sections.length > 0) {
       // Custom dashboard view.
-      this._setParams({
+      this.setParams({
         view: GerritView.DASHBOARD,
         user: 'self',
         sections,
@@ -1276,13 +1392,13 @@
     }
 
     // Redirect /dashboard/ -> /dashboard/self.
-    this._redirect('/dashboard/self');
+    this.redirect('/dashboard/self');
     return Promise.resolve();
   }
 
-  _handleProjectDashboardRoute(data: PageContextWithQueryMap) {
+  handleProjectDashboardRoute(data: PageContextWithQueryMap) {
     const project = data.params[0] as RepoName;
-    this._setParams({
+    this.setParams({
       view: GerritView.DASHBOARD,
       project,
       dashboard: decodeURIComponent(data.params[1]) as DashboardId,
@@ -1290,43 +1406,43 @@
     this.reporting.setRepoName(project);
   }
 
-  _handleLegacyProjectDashboardRoute(data: PageContextWithQueryMap) {
-    this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
+  handleLegacyProjectDashboardRoute(data: PageContextWithQueryMap) {
+    this.redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
   }
 
-  _handleGroupInfoRoute(data: PageContextWithQueryMap) {
-    this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
+  handleGroupInfoRoute(data: PageContextWithQueryMap) {
+    this.redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
   }
 
-  _handleGroupSelfRedirectRoute(_: PageContextWithQueryMap) {
-    this._redirect('/settings/#Groups');
+  handleGroupSelfRedirectRoute(_: PageContextWithQueryMap) {
+    this.redirect('/settings/#Groups');
   }
 
-  _handleGroupRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.GROUP,
       groupId: data.params[0] as GroupId,
     });
   }
 
-  _handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.GROUP,
       detail: GroupDetailView.LOG,
       groupId: data.params[0] as GroupId,
     });
   }
 
-  _handleGroupMembersRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupMembersRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.GROUP,
       detail: GroupDetailView.MEMBERS,
       groupId: data.params[0] as GroupId,
     });
   }
 
-  _handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-admin-group-list',
       offset: data.params[1] || 0,
@@ -1335,8 +1451,8 @@
     });
   }
 
-  _handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-admin-group-list',
       offset: data.params['offset'],
@@ -1344,15 +1460,15 @@
     });
   }
 
-  _handleGroupListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupListFilterRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-admin-group-list',
       filter: data.params['filter'] || null,
     });
   }
 
-  _handleProjectsOldRoute(data: PageContextWithQueryMap) {
+  handleProjectsOldRoute(data: PageContextWithQueryMap) {
     let params = '';
     if (data.params[1]) {
       params = encodeURIComponent(data.params[1]);
@@ -1361,12 +1477,12 @@
       }
     }
 
-    this._redirect(`/admin/repos/${params}`);
+    this.redirect(`/admin/repos/${params}`);
   }
 
-  _handleRepoCommandsRoute(data: PageContextWithQueryMap) {
+  handleRepoCommandsRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this._setParams({
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.COMMANDS,
       repo,
@@ -1374,9 +1490,9 @@
     this.reporting.setRepoName(repo);
   }
 
-  _handleRepoGeneralRoute(data: PageContextWithQueryMap) {
+  handleRepoGeneralRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this._setParams({
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.GENERAL,
       repo,
@@ -1384,9 +1500,9 @@
     this.reporting.setRepoName(repo);
   }
 
-  _handleRepoAccessRoute(data: PageContextWithQueryMap) {
+  handleRepoAccessRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this._setParams({
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.ACCESS,
       repo,
@@ -1394,9 +1510,9 @@
     this.reporting.setRepoName(repo);
   }
 
-  _handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
+  handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this._setParams({
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.DASHBOARDS,
       repo,
@@ -1404,8 +1520,8 @@
     this.reporting.setRepoName(repo);
   }
 
-  _handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params[0] as RepoName,
@@ -1414,8 +1530,8 @@
     });
   }
 
-  _handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params['repo'] as RepoName,
@@ -1424,8 +1540,8 @@
     });
   }
 
-  _handleBranchListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListFilterRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params['repo'] as RepoName,
@@ -1433,8 +1549,8 @@
     });
   }
 
-  _handleTagListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params[0] as RepoName,
@@ -1443,8 +1559,8 @@
     });
   }
 
-  _handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params['repo'] as RepoName,
@@ -1453,8 +1569,8 @@
     });
   }
 
-  _handleTagListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListFilterRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params['repo'] as RepoName,
@@ -1462,8 +1578,8 @@
     });
   }
 
-  _handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-repo-list',
       offset: data.params[1] || 0,
@@ -1472,8 +1588,8 @@
     });
   }
 
-  _handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-repo-list',
       offset: data.params['offset'],
@@ -1481,32 +1597,32 @@
     });
   }
 
-  _handleRepoListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListFilterRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-repo-list',
       filter: data.params['filter'] || null,
     });
   }
 
-  _handleCreateProjectRoute(_: PageContextWithQueryMap) {
+  handleCreateProjectRoute(_: PageContextWithQueryMap) {
     // Redirects the legacy route to the new route, which displays the project
     // list with a hash 'create'.
-    this._redirect('/admin/repos#create');
+    this.redirect('/admin/repos#create');
   }
 
-  _handleCreateGroupRoute(_: PageContextWithQueryMap) {
+  handleCreateGroupRoute(_: PageContextWithQueryMap) {
     // Redirects the legacy route to the new route, which displays the group
     // list with a hash 'create'.
-    this._redirect('/admin/groups#create');
+    this.redirect('/admin/groups#create');
   }
 
-  _handleRepoRoute(data: PageContextWithQueryMap) {
-    this._redirect(data.path + ',general');
+  handleRepoRoute(data: PageContextWithQueryMap) {
+    this.redirect(data.path + ',general');
   }
 
-  _handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-plugin-list',
       offset: data.params[1] || 0,
@@ -1514,8 +1630,8 @@
     });
   }
 
-  _handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-plugin-list',
       offset: data.params['offset'],
@@ -1523,48 +1639,48 @@
     });
   }
 
-  _handlePluginListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListFilterRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-plugin-list',
       filter: data.params['filter'] || null,
     });
   }
 
-  _handlePluginListRoute(_: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListRoute(_: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-plugin-list',
     });
   }
 
-  _handleQueryRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleQueryRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.SEARCH,
       query: data.params[0],
       offset: data.params[2],
     });
   }
 
-  _handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
+  handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
     // TODO(pcc): This will need to indicate that this was a change ID query if
     // standard queries gain the ability to search places like commit messages
     // for change IDs.
-    this._setParams({
+    this.setParams({
       view: GerritNav.View.SEARCH,
       query: data.params[0],
     });
   }
 
-  _handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
-    this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
+  handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
+    this.redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
   }
 
-  _handleChangeNumberLegacyRoute(ctx: PageContextWithQueryMap) {
-    this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+  handleChangeNumberLegacyRoute(ctx: PageContextWithQueryMap) {
+    this.redirect('/c/' + encodeURIComponent(ctx.params[0]));
   }
 
-  _handleChangeRoute(ctx: PageContextWithQueryMap) {
+  handleChangeRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const params: GenerateUrlChangeViewParameters = {
@@ -1600,10 +1716,10 @@
 
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
-    this._redirectOrNavigate(params);
+    this.redirectOrNavigate(params);
   }
 
-  _handleCommentRoute(ctx: PageContextWithQueryMap) {
+  handleCommentRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
@@ -1614,10 +1730,10 @@
     };
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
-    this._redirectOrNavigate(params);
+    this.redirectOrNavigate(params);
   }
 
-  _handleCommentsRoute(ctx: PageContextWithQueryMap) {
+  handleCommentsRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const params: GenerateUrlChangeViewParameters = {
       project: ctx.params[0] as RepoName,
@@ -1627,10 +1743,10 @@
     };
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
-    this._redirectOrNavigate(params);
+    this.redirectOrNavigate(params);
   }
 
-  _handleDiffRoute(ctx: PageContextWithQueryMap) {
+  handleDiffRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlDiffViewParameters = {
@@ -1641,42 +1757,42 @@
       path: ctx.params[8],
       view: GerritView.DIFF,
     };
-    const address = this._parseLineAddress(ctx.hash);
+    const address = this.parseLineAddress(ctx.hash);
     if (address) {
       params.leftSide = address.leftSide;
       params.lineNum = address.lineNum;
     }
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
-    this._redirectOrNavigate(params);
+    this.redirectOrNavigate(params);
   }
 
-  _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
+  handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[0]) as NumericChangeId;
     if (!changeNum) {
-      this._show404();
+      this.show404();
       return;
     }
     this.restApiService.getFromProjectLookup(changeNum).then(project => {
       // Show a 404 and terminate if the lookup request failed. Attempting
       // to redirect after failing to get the project loops infinitely.
       if (!project) {
-        this._show404();
+        this.show404();
         return;
       }
-      this._redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
+      this.redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
     });
   }
 
-  _handleLegacyLinenum(ctx: PageContextWithQueryMap) {
-    this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
+  handleLegacyLinenum(ctx: PageContextWithQueryMap) {
+    this.redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
   }
 
-  _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
+  handleDiffEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    this._redirectOrNavigate({
+    this.redirectOrNavigate({
       project,
       changeNum,
       // for edit view params, patchNum cannot be undefined
@@ -1689,7 +1805,7 @@
     this.reporting.setChangeId(changeNum);
   }
 
-  _handleChangeEditRoute(ctx: PageContextWithQueryMap) {
+  handleChangeEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
@@ -1709,7 +1825,7 @@
         location.href.replace(/[?&]forceReload=true/, '')
       );
     }
-    this._redirectOrNavigate(params);
+    this.redirectOrNavigate(params);
 
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
@@ -1719,42 +1835,42 @@
    * Normalize the patch range params for a the change or diff view and
    * redirect if URL upgrade is needed.
    */
-  _redirectOrNavigate(params: GenerateUrlParameters & PatchRangeParams) {
-    const needsRedirect = this._normalizePatchRangeParams(params);
+  private redirectOrNavigate(params: GenerateUrlParameters & PatchRangeParams) {
+    const needsRedirect = this.normalizePatchRangeParams(params);
     if (needsRedirect) {
-      this._redirect(this._generateUrl(params));
+      this.redirect(this.generateUrl(params));
     } else {
-      this._setParams(params);
+      this.setParams(params);
     }
   }
 
-  _handleAgreementsRoute() {
-    this._redirect('/settings/#Agreements');
+  handleAgreementsRoute() {
+    this.redirect('/settings/#Agreements');
   }
 
-  _handleNewAgreementsRoute(data: PageContextWithQueryMap) {
+  handleNewAgreementsRoute(data: PageContextWithQueryMap) {
     data.params['view'] = GerritView.AGREEMENTS;
     // TODO(TS): create valid object
-    this._setParams(data.params as unknown as AppElementAgreementParam);
+    this.setParams(data.params as unknown as AppElementAgreementParam);
   }
 
-  _handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
+  handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
     // email tokens may contain '+' but no space.
     // The parameter parsing replaces all '+' with a space,
     // undo that to have valid tokens.
     const token = data.params[0].replace(/ /g, '+');
-    this._setParams({
+    this.setParams({
       view: GerritView.SETTINGS,
       emailToken: token,
     });
   }
 
-  _handleSettingsRoute(_: PageContextWithQueryMap) {
-    this._setParams({view: GerritView.SETTINGS});
+  handleSettingsRoute(_: PageContextWithQueryMap) {
+    this.setParams({view: GerritView.SETTINGS});
   }
 
-  _handleRegisterRoute(ctx: PageContextWithQueryMap) {
-    this._setParams({justRegistered: true});
+  handleRegisterRoute(ctx: PageContextWithQueryMap) {
+    this.setParams({justRegistered: true});
     let path = ctx.params[0] || '/';
 
     // Prevent redirect looping.
@@ -1765,14 +1881,14 @@
     if (path[0] !== '/') {
       return;
     }
-    this._redirect(getBaseUrl() + path);
+    this.redirect(getBaseUrl() + path);
   }
 
   /**
    * Handler for routes that should pass through the router and not be caught
    * by the catchall _handleDefaultRoute handler.
    */
-  _handlePassThroughRoute() {
+  handlePassThroughRoute() {
     windowLocationReload();
   }
 
@@ -1780,66 +1896,60 @@
    * URL may sometimes have /+/ encoded to / /.
    * Context: Issue 6888, Issue 7100
    */
-  _handleImproperlyEncodedPlusRoute(ctx: PageContextWithQueryMap) {
-    let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
+  handleImproperlyEncodedPlusRoute(ctx: PageContextWithQueryMap) {
+    let hash = this.getHashFromCanonicalPath(ctx.canonicalPath);
     if (hash.length) {
       hash = '#' + hash;
     }
-    this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
+    this.redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
   }
 
-  _handlePluginScreen(ctx: PageContextWithQueryMap) {
+  handlePluginScreen(ctx: PageContextWithQueryMap) {
     const view = GerritView.PLUGIN_SCREEN;
     const plugin = ctx.params[0];
     const screen = ctx.params[1];
-    this._setParams({view, plugin, screen});
+    this.setParams({view, plugin, screen});
   }
 
-  _handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.DOCUMENTATION_SEARCH,
       filter: data.params['filter'] || null,
     });
   }
 
-  _handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
-    this._redirect(
+  handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
+    this.redirect(
       '/Documentation/q/filter:' + encodeURIComponent(data.params[0])
     );
   }
 
-  _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
+  handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
     if (data.params[1]) {
       windowLocationReload();
     } else {
       // Redirect /Documentation to /Documentation/index.html
-      this._redirect('/Documentation/index.html');
+      this.redirect('/Documentation/index.html');
     }
   }
 
   /**
    * Catchall route for when no other route is matched.
    */
-  _handleDefaultRoute() {
+  handleDefaultRoute() {
     if (this._isInitialLoad) {
       // Server recognized this route as polygerrit, so we show 404.
-      this._show404();
+      this.show404();
     } else {
       // Route can be recognized by server, so we pass it to server.
-      this._handlePassThroughRoute();
+      this.handlePassThroughRoute();
     }
   }
 
-  _show404() {
+  private show404() {
     // Note: the app's 404 display is tightly-coupled with catching 404
     // network responses, so we simulate a 404 response status to display it.
     // TODO: Decouple the gr-app error view from network responses.
     firePageError(new Response('', {status: 404}));
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-router': GrRouter;
-  }
-}
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 0b9921c..4c147bb 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -63,18 +63,16 @@
 } from '../../../test/test-data-generators';
 import {AppElementParams} from '../../gr-app-types';
 
-const basicFixture = fixtureFromElement('gr-router');
-
 suite('gr-router tests', () => {
-  let element: GrRouter;
+  let router: GrRouter;
 
   setup(() => {
-    element = basicFixture.instantiate();
+    router = new GrRouter();
   });
 
-  test('_firstCodeBrowserWeblink', () => {
+  test('firstCodeBrowserWeblink', () => {
     assert.deepEqual(
-      element._firstCodeBrowserWeblink([
+      router.firstCodeBrowserWeblink([
         {name: 'gitweb'},
         {name: 'gitiles'},
         {name: 'browse'},
@@ -84,12 +82,12 @@
     );
 
     assert.deepEqual(
-      element._firstCodeBrowserWeblink([{name: 'gitweb'}, {name: 'test'}]),
+      router.firstCodeBrowserWeblink([{name: 'gitweb'}, {name: 'test'}]),
       {name: 'gitweb'}
     );
   });
 
-  test('_getBrowseCommitWeblink', () => {
+  test('getBrowseCommitWeblink', () => {
     const browserLink = {name: 'browser', url: 'browser/url'};
     const link = {name: 'test', url: 'test/url'};
     const weblinks = [browserLink, link];
@@ -97,17 +95,17 @@
       ...createServerInfo(),
       gerrit: {...createGerritInfo(), primary_weblink_name: browserLink.name},
     };
-    sinon.stub(element, '_firstCodeBrowserWeblink').returns(link);
+    sinon.stub(router, 'firstCodeBrowserWeblink').returns(link);
 
     assert.deepEqual(
-      element._getBrowseCommitWeblink(weblinks, config),
+      router.getBrowseCommitWeblink(weblinks, config),
       browserLink
     );
 
-    assert.deepEqual(element._getBrowseCommitWeblink(weblinks), link);
+    assert.deepEqual(router.getBrowseCommitWeblink(weblinks), link);
   });
 
-  test('_getChangeWeblinks', () => {
+  test('getChangeWeblinks', () => {
     const link = {name: 'test', url: 'test/url'};
     const browserLink = {name: 'browser', url: 'browser/url'};
     const mapLinksToConfig = (weblinks: WebLinkInfo[]) => {
@@ -118,81 +116,81 @@
         options: {weblinks},
       };
     };
-    sinon.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
+    sinon.stub(router, 'getBrowseCommitWeblink').returns(browserLink);
 
     assert.deepEqual(
-      element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
+      router.getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
       {name: 'test', url: 'test/url'}
     );
 
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0], {
+    assert.deepEqual(router.getChangeWeblinks(mapLinksToConfig([link]))[0], {
       name: 'test',
       url: 'test/url',
     });
 
     link.url = `https://${link.url}`;
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0], {
+    assert.deepEqual(router.getChangeWeblinks(mapLinksToConfig([link]))[0], {
       name: 'test',
       url: 'https://test/url',
     });
   });
 
-  test('_getHashFromCanonicalPath', () => {
+  test('getHashFromCanonicalPath', () => {
     let url = '/foo/bar';
-    let hash = element._getHashFromCanonicalPath(url);
+    let hash = router.getHashFromCanonicalPath(url);
     assert.equal(hash, '');
 
     url = '';
-    hash = element._getHashFromCanonicalPath(url);
+    hash = router.getHashFromCanonicalPath(url);
     assert.equal(hash, '');
 
     url = '/foo#bar';
-    hash = element._getHashFromCanonicalPath(url);
+    hash = router.getHashFromCanonicalPath(url);
     assert.equal(hash, 'bar');
 
     url = '/foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
+    hash = router.getHashFromCanonicalPath(url);
     assert.equal(hash, 'bar#baz');
 
     url = '#foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
+    hash = router.getHashFromCanonicalPath(url);
     assert.equal(hash, 'foo#bar#baz');
   });
 
-  suite('_parseLineAddress', () => {
+  suite('parseLineAddress', () => {
     test('returns null for empty and invalid hashes', () => {
-      let actual = element._parseLineAddress('');
+      let actual = router.parseLineAddress('');
       assert.isNull(actual);
 
-      actual = element._parseLineAddress('foobar');
+      actual = router.parseLineAddress('foobar');
       assert.isNull(actual);
 
-      actual = element._parseLineAddress('foo123');
+      actual = router.parseLineAddress('foo123');
       assert.isNull(actual);
 
-      actual = element._parseLineAddress('123bar');
+      actual = router.parseLineAddress('123bar');
       assert.isNull(actual);
     });
 
     test('parses correctly', () => {
-      let actual = element._parseLineAddress('1234');
+      let actual = router.parseLineAddress('1234');
       assert.isOk(actual);
       assert.equal(actual!.lineNum, 1234);
       assert.isFalse(actual!.leftSide);
 
-      actual = element._parseLineAddress('a4');
+      actual = router.parseLineAddress('a4');
       assert.isOk(actual);
       assert.equal(actual!.lineNum, 4);
       assert.isTrue(actual!.leftSide);
 
-      actual = element._parseLineAddress('b77');
+      actual = router.parseLineAddress('b77');
       assert.isOk(actual);
       assert.equal(actual!.lineNum, 77);
       assert.isTrue(actual!.leftSide);
     });
   });
 
-  test('_startRouter requires auth for the right handlers', () => {
+  test('startRouter requires auth for the right handlers', () => {
     // This test encodes the lists of route handler methods that gr-router
     // automatically checks for authentication before triggering.
 
@@ -202,15 +200,15 @@
     sinon.stub(page, 'start');
     sinon.stub(page, 'base');
     sinon
-      .stub(element, '_mapRoute')
-      .callsFake((_pattern, methodName, usesAuth) => {
+      .stub(router, 'mapRoute')
+      .callsFake((_pattern, methodName, _method, usesAuth) => {
         if (usesAuth) {
           requiresAuth[methodName] = true;
         } else {
           doesNotRequireAuth[methodName] = true;
         }
       });
-    element._startRouter();
+    router.startRouter();
 
     const actualRequiresAuth = Object.keys(requiresAuth);
     actualRequiresAuth.sort();
@@ -218,73 +216,73 @@
     actualDoesNotRequireAuth.sort();
 
     const shouldRequireAutoAuth = [
-      '_handleAgreementsRoute',
-      '_handleChangeEditRoute',
-      '_handleCreateGroupRoute',
-      '_handleCreateProjectRoute',
-      '_handleDiffEditRoute',
-      '_handleGroupAuditLogRoute',
-      '_handleGroupInfoRoute',
-      '_handleGroupListFilterOffsetRoute',
-      '_handleGroupListFilterRoute',
-      '_handleGroupListOffsetRoute',
-      '_handleGroupMembersRoute',
-      '_handleGroupRoute',
-      '_handleGroupSelfRedirectRoute',
-      '_handleNewAgreementsRoute',
-      '_handlePluginListFilterOffsetRoute',
-      '_handlePluginListFilterRoute',
-      '_handlePluginListOffsetRoute',
-      '_handlePluginListRoute',
-      '_handleRepoCommandsRoute',
-      '_handleSettingsLegacyRoute',
-      '_handleSettingsRoute',
+      'handleAgreementsRoute',
+      'handleChangeEditRoute',
+      'handleCreateGroupRoute',
+      'handleCreateProjectRoute',
+      'handleDiffEditRoute',
+      'handleGroupAuditLogRoute',
+      'handleGroupInfoRoute',
+      'handleGroupListFilterOffsetRoute',
+      'handleGroupListFilterRoute',
+      'handleGroupListOffsetRoute',
+      'handleGroupMembersRoute',
+      'handleGroupRoute',
+      'handleGroupSelfRedirectRoute',
+      'handleNewAgreementsRoute',
+      'handlePluginListFilterOffsetRoute',
+      'handlePluginListFilterRoute',
+      'handlePluginListOffsetRoute',
+      'handlePluginListRoute',
+      'handleRepoCommandsRoute',
+      'handleSettingsLegacyRoute',
+      'handleSettingsRoute',
     ];
     assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
 
     const unauthenticatedHandlers = [
-      '_handleBranchListFilterOffsetRoute',
-      '_handleBranchListFilterRoute',
-      '_handleBranchListOffsetRoute',
-      '_handleChangeIdQueryRoute',
-      '_handleChangeNumberLegacyRoute',
-      '_handleChangeRoute',
-      '_handleCommentRoute',
-      '_handleCommentsRoute',
-      '_handleDiffRoute',
-      '_handleDefaultRoute',
-      '_handleChangeLegacyRoute',
-      '_handleDocumentationRedirectRoute',
-      '_handleDocumentationSearchRoute',
-      '_handleDocumentationSearchRedirectRoute',
-      '_handleLegacyLinenum',
-      '_handleImproperlyEncodedPlusRoute',
-      '_handlePassThroughRoute',
-      '_handleProjectDashboardRoute',
-      '_handleLegacyProjectDashboardRoute',
-      '_handleProjectsOldRoute',
-      '_handleRepoAccessRoute',
-      '_handleRepoDashboardsRoute',
-      '_handleRepoGeneralRoute',
-      '_handleRepoListFilterOffsetRoute',
-      '_handleRepoListFilterRoute',
-      '_handleRepoListOffsetRoute',
-      '_handleRepoRoute',
-      '_handleQueryLegacySuffixRoute',
-      '_handleQueryRoute',
-      '_handleRegisterRoute',
-      '_handleTagListFilterOffsetRoute',
-      '_handleTagListFilterRoute',
-      '_handleTagListOffsetRoute',
-      '_handlePluginScreen',
+      'handleBranchListFilterOffsetRoute',
+      'handleBranchListFilterRoute',
+      'handleBranchListOffsetRoute',
+      'handleChangeIdQueryRoute',
+      'handleChangeNumberLegacyRoute',
+      'handleChangeRoute',
+      'handleCommentRoute',
+      'handleCommentsRoute',
+      'handleDiffRoute',
+      'handleDefaultRoute',
+      'handleChangeLegacyRoute',
+      'handleDocumentationRedirectRoute',
+      'handleDocumentationSearchRoute',
+      'handleDocumentationSearchRedirectRoute',
+      'handleLegacyLinenum',
+      'handleImproperlyEncodedPlusRoute',
+      'handlePassThroughRoute',
+      'handleProjectDashboardRoute',
+      'handleLegacyProjectDashboardRoute',
+      'handleProjectsOldRoute',
+      'handleRepoAccessRoute',
+      'handleRepoDashboardsRoute',
+      'handleRepoGeneralRoute',
+      'handleRepoListFilterOffsetRoute',
+      'handleRepoListFilterRoute',
+      'handleRepoListOffsetRoute',
+      'handleRepoRoute',
+      'handleQueryLegacySuffixRoute',
+      'handleQueryRoute',
+      'handleRegisterRoute',
+      'handleTagListFilterOffsetRoute',
+      'handleTagListFilterRoute',
+      'handleTagListOffsetRoute',
+      'handlePluginScreen',
     ];
 
     // Handler names that check authentication themselves, and thus don't need
     // it performed for them.
     const selfAuthenticatingHandlers = [
-      '_handleDashboardRoute',
-      '_handleCustomDashboardRoute',
-      '_handleRootRoute',
+      'handleDashboardRoute',
+      'handleCustomDashboardRoute',
+      'handleRootRoute',
     ];
 
     const shouldNotRequireAuth = unauthenticatedHandlers.concat(
@@ -294,7 +292,7 @@
     assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
   });
 
-  test('_redirectIfNotLoggedIn while logged in', () => {
+  test('redirectIfNotLoggedIn while logged in', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(true));
     const data = {
       save() {},
@@ -308,15 +306,15 @@
       hash: '',
       params: {test: 'test'},
     };
-    const redirectStub = sinon.stub(element, '_redirectToLogin');
-    return element._redirectIfNotLoggedIn(data).then(() => {
+    const redirectStub = sinon.stub(router, 'redirectToLogin');
+    return router.redirectIfNotLoggedIn(data).then(() => {
       assert.isFalse(redirectStub.called);
     });
   });
 
-  test('_redirectIfNotLoggedIn while logged out', () => {
+  test('redirectIfNotLoggedIn while logged out', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    const redirectStub = sinon.stub(element, '_redirectToLogin');
+    const redirectStub = sinon.stub(router, 'redirectToLogin');
     const data = {
       save() {},
       handled: true,
@@ -330,8 +328,8 @@
       params: {test: 'test'},
     };
     return new Promise(resolve => {
-      element
-        ._redirectIfNotLoggedIn(data)
+      router
+        .redirectIfNotLoggedIn(data)
         .then(() => {
           assert.isTrue(false, 'Should never execute');
         })
@@ -353,14 +351,14 @@
         statuses: ['op%en'],
       };
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
           'topic:g%2525h+status:op%2525en'
       );
 
       params.offset = 100;
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
           'topic:g%2525h+status:op%2525en,100'
       );
@@ -368,17 +366,17 @@
 
       // The presence of the query param overrides other params.
       params.query = 'foo$bar';
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar');
+      assert.equal(router.generateUrl(params), '/q/foo%2524bar');
 
       params.offset = 100;
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
+      assert.equal(router.generateUrl(params), '/q/foo%2524bar,100');
 
       params = {
         view: GerritNav.View.SEARCH,
         statuses: ['a', 'b', 'c'],
       };
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/q/(status:a OR status:b OR status:c)'
       );
 
@@ -386,17 +384,17 @@
         view: GerritNav.View.SEARCH,
         topic: 'test' as TopicName,
       };
-      assert.equal(element._generateUrl(params), '/q/topic:test');
+      assert.equal(router.generateUrl(params), '/q/topic:test');
       params = {
         view: GerritNav.View.SEARCH,
         topic: 'test test' as TopicName,
       };
-      assert.equal(element._generateUrl(params), '/q/topic:"test+test"');
+      assert.equal(router.generateUrl(params), '/q/topic:"test+test"');
       params = {
         view: GerritNav.View.SEARCH,
         topic: 'test:test' as TopicName,
       };
-      assert.equal(element._generateUrl(params), '/q/topic:"test:test"');
+      assert.equal(router.generateUrl(params), '/q/topic:"test:test"');
     });
 
     test('change', () => {
@@ -406,16 +404,16 @@
         project: 'test' as RepoName,
       };
 
-      assert.equal(element._generateUrl(params), '/c/test/+/1234');
+      assert.equal(router.generateUrl(params), '/c/test/+/1234');
 
       params.patchNum = 10 as PatchSetNum;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
+      assert.equal(router.generateUrl(params), '/c/test/+/1234/10');
 
       params.basePatchNum = 5 as BasePatchSetNum;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
+      assert.equal(router.generateUrl(params), '/c/test/+/1234/5..10');
 
       params.messageHash = '#123';
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
+      assert.equal(router.generateUrl(params), '/c/test/+/1234/5..10#123');
     });
 
     test('change with repo name encoding', () => {
@@ -425,7 +423,7 @@
         project: 'x+/y+/z+/w' as RepoName,
       };
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/c/x%252B/y%252B/z%252B/w/+/1234'
       );
     });
@@ -438,17 +436,17 @@
         patchNum: 12 as PatchSetNum,
         project: '' as RepoName,
       };
-      assert.equal(element._generateUrl(params), '/c/42/12/x%252By/path.cpp');
+      assert.equal(router.generateUrl(params), '/c/42/12/x%252By/path.cpp');
 
       params.project = 'test' as RepoName;
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/c/test/+/42/12/x%252By/path.cpp'
       );
 
       params.basePatchNum = 6 as BasePatchSetNum;
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/c/test/+/42/6..12/x%252By/path.cpp'
       );
 
@@ -456,19 +454,16 @@
       params.patchNum = 2 as PatchSetNum;
       delete params.basePatchNum;
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
       );
 
       params.path = 'file.cpp';
       params.lineNum = 123;
-      assert.equal(element._generateUrl(params), '/c/test/+/42/2/file.cpp#123');
+      assert.equal(router.generateUrl(params), '/c/test/+/42/2/file.cpp#123');
 
       params.leftSide = true;
-      assert.equal(
-        element._generateUrl(params),
-        '/c/test/+/42/2/file.cpp#b123'
-      );
+      assert.equal(router.generateUrl(params), '/c/test/+/42/2/file.cpp#b123');
     });
 
     test('diff with repo name encoding', () => {
@@ -480,7 +475,7 @@
         project: 'x+/y' as RepoName,
       };
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/c/x%252B/y/+/42/12/x%252By/path.cpp'
       );
     });
@@ -494,26 +489,26 @@
         patchNum: 'edit' as PatchSetNum,
       };
       assert.equal(
-        element._generateUrl(params),
+        router.generateUrl(params),
         '/c/test/+/42/edit/x%252By/path.cpp,edit'
       );
     });
 
-    test('_getPatchRangeExpression', () => {
+    test('getPatchRangeExpression', () => {
       const params: PatchRangeParams = {};
-      let actual = element._getPatchRangeExpression(params);
+      let actual = router.getPatchRangeExpression(params);
       assert.equal(actual, '');
 
       params.patchNum = 4 as PatchSetNum;
-      actual = element._getPatchRangeExpression(params);
+      actual = router.getPatchRangeExpression(params);
       assert.equal(actual, '4');
 
       params.basePatchNum = 2 as BasePatchSetNum;
-      actual = element._getPatchRangeExpression(params);
+      actual = router.getPatchRangeExpression(params);
       assert.equal(actual, '2..4');
 
       delete params.patchNum;
-      actual = element._getPatchRangeExpression(params);
+      actual = router.getPatchRangeExpression(params);
       assert.equal(actual, '2..');
     });
 
@@ -522,7 +517,7 @@
         const params: GenerateUrlDashboardViewParameters = {
           view: GerritView.DASHBOARD,
         };
-        assert.equal(element._generateUrl(params), '/dashboard/self');
+        assert.equal(router.generateUrl(params), '/dashboard/self');
       });
 
       test('user dashboard', () => {
@@ -530,7 +525,7 @@
           view: GerritView.DASHBOARD,
           user: 'user',
         };
-        assert.equal(element._generateUrl(params), '/dashboard/user');
+        assert.equal(router.generateUrl(params), '/dashboard/user');
       });
 
       test('custom self dashboard, no title', () => {
@@ -542,7 +537,7 @@
           ],
         };
         assert.equal(
-          element._generateUrl(params),
+          router.generateUrl(params),
           '/dashboard/?section%201=query%201&section%202=query%202'
         );
       });
@@ -557,7 +552,7 @@
           repo: 'repo-name' as RepoName,
         };
         assert.equal(
-          element._generateUrl(params),
+          router.generateUrl(params),
           '/dashboard/?section%201=query%201%20repo-name&' +
             'section%202=query%202%20repo-name'
         );
@@ -571,7 +566,7 @@
           title: 'custom dashboard',
         };
         assert.equal(
-          element._generateUrl(params),
+          router.generateUrl(params),
           '/dashboard/user?name=query&title=custom%20dashboard'
         );
       });
@@ -583,7 +578,7 @@
           dashboard: 'default:main' as DashboardId,
         };
         assert.equal(
-          element._generateUrl(params),
+          router.generateUrl(params),
           '/p/gerrit/repo/+/dashboard/default:main'
         );
       });
@@ -595,7 +590,7 @@
           dashboard: 'default:main' as DashboardId,
         };
         assert.equal(
-          element._generateUrl(params),
+          router.generateUrl(params),
           '/p/gerrit/project/+/dashboard/default:main'
         );
       });
@@ -607,7 +602,7 @@
           view: GerritView.GROUP,
           groupId: '1234' as GroupId,
         };
-        assert.equal(element._generateUrl(params), '/admin/groups/1234');
+        assert.equal(router.generateUrl(params), '/admin/groups/1234');
       });
 
       test('group members', () => {
@@ -616,10 +611,7 @@
           groupId: '1234' as GroupId,
           detail: 'members' as GroupDetailView,
         };
-        assert.equal(
-          element._generateUrl(params),
-          '/admin/groups/1234,members'
-        );
+        assert.equal(router.generateUrl(params), '/admin/groups/1234,members');
       });
 
       test('group audit log', () => {
@@ -629,7 +621,7 @@
           detail: 'log' as GroupDetailView,
         };
         assert.equal(
-          element._generateUrl(params),
+          router.generateUrl(params),
           '/admin/groups/1234,audit-log'
         );
       });
@@ -637,13 +629,13 @@
   });
 
   suite('param normalization', () => {
-    suite('_normalizePatchRangeParams', () => {
+    suite('normalizePatchRangeParams', () => {
       test('range n..n normalizes to n', () => {
         const params: PatchRangeParams = {
           basePatchNum: 4 as BasePatchSetNum,
           patchNum: 4 as PatchSetNum,
         };
-        const needsRedirect = element._normalizePatchRangeParams(params);
+        const needsRedirect = router.normalizePatchRangeParams(params);
         assert.isTrue(needsRedirect);
         assert.equal(params.basePatchNum, ParentPatchSetNum);
         assert.equal(params.patchNum, 4 as PatchSetNum);
@@ -651,7 +643,7 @@
 
       test('range n.. normalizes to n', () => {
         const params: PatchRangeParams = {basePatchNum: 4 as BasePatchSetNum};
-        const needsRedirect = element._normalizePatchRangeParams(params);
+        const needsRedirect = router.normalizePatchRangeParams(params);
         assert.isFalse(needsRedirect);
         assert.equal(params.basePatchNum, ParentPatchSetNum);
         assert.equal(params.patchNum, 4 as PatchSetNum);
@@ -672,7 +664,7 @@
       methodName: string,
       params: AppElementParams | GenerateUrlParameters
     ) {
-      (element as any)[methodName](data);
+      (router as any)[methodName](data);
       assert.deepEqual(setParamsStub.lastCall.args[0], params);
     }
 
@@ -693,17 +685,17 @@
     }
 
     setup(() => {
-      redirectStub = sinon.stub(element, '_redirect');
-      setParamsStub = sinon.stub(element, '_setParams');
-      handlePassThroughRoute = sinon.stub(element, '_handlePassThroughRoute');
+      redirectStub = sinon.stub(router, 'redirect');
+      setParamsStub = sinon.stub(router, 'setParams');
+      handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
     });
 
-    test('_handleLegacyProjectDashboardRoute', () => {
+    test('handleLegacyProjectDashboardRoute', () => {
       const params = {
         ...createPageContext(),
         params: {0: 'gerrit/project', 1: 'dashboard:main'},
       };
-      element._handleLegacyProjectDashboardRoute(params);
+      router.handleLegacyProjectDashboardRoute(params);
       assert.isTrue(redirectStub.calledOnce);
       assert.equal(
         redirectStub.lastCall.args[0],
@@ -711,15 +703,15 @@
       );
     });
 
-    test('_handleAgreementsRoute', () => {
-      element._handleAgreementsRoute();
+    test('handleAgreementsRoute', () => {
+      router.handleAgreementsRoute();
       assert.isTrue(redirectStub.calledOnce);
       assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
     });
 
-    test('_handleNewAgreementsRoute', () => {
+    test('handleNewAgreementsRoute', () => {
       const params = createPageContext();
-      element._handleNewAgreementsRoute(params);
+      router.handleNewAgreementsRoute(params);
       assert.isTrue(setParamsStub.calledOnce);
       assert.equal(
         setParamsStub.lastCall.args[0].view,
@@ -727,38 +719,38 @@
       );
     });
 
-    test('_handleSettingsLegacyRoute', () => {
+    test('handleSettingsLegacyRoute', () => {
       const data = {...createPageContext(), params: {0: 'my-token'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+      assertDataToParams(data, 'handleSettingsLegacyRoute', {
         view: GerritNav.View.SETTINGS,
         emailToken: 'my-token',
       });
     });
 
-    test('_handleSettingsLegacyRoute with +', () => {
+    test('handleSettingsLegacyRoute with +', () => {
       const data = {...createPageContext(), params: {0: 'my-token test'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+      assertDataToParams(data, 'handleSettingsLegacyRoute', {
         view: GerritNav.View.SETTINGS,
         emailToken: 'my-token+test',
       });
     });
 
-    test('_handleSettingsRoute', () => {
+    test('handleSettingsRoute', () => {
       const data = createPageContext();
-      assertDataToParams(data, '_handleSettingsRoute', {
+      assertDataToParams(data, 'handleSettingsRoute', {
         view: GerritNav.View.SETTINGS,
       });
     });
 
-    test('_handleDefaultRoute on first load', () => {
+    test('handleDefaultRoute on first load', () => {
       const spy = sinon.spy();
       addListenerForTest(document, 'page-error', spy);
-      element._handleDefaultRoute();
+      router.handleDefaultRoute();
       assert.isTrue(spy.calledOnce);
       assert.equal(spy.lastCall.args[0].detail.response.status, 404);
     });
 
-    test('_handleDefaultRoute after internal navigation', () => {
+    test('handleDefaultRoute after internal navigation', () => {
       let onExit: Function | null = null;
       const onRegisteringExit = (
         _match: string | RegExp,
@@ -770,38 +762,38 @@
       sinon.stub(GerritNav, 'setup');
       sinon.stub(page, 'start');
       sinon.stub(page, 'base');
-      element._startRouter();
+      router.startRouter();
 
-      element._handleDefaultRoute();
+      router.handleDefaultRoute();
 
       onExit!('', () => {}); // we left page;
 
-      element._handleDefaultRoute();
+      router.handleDefaultRoute();
       assert.isTrue(handlePassThroughRoute.calledOnce);
     });
 
-    test('_handleImproperlyEncodedPlusRoute', () => {
+    test('handleImproperlyEncodedPlusRoute', () => {
       const params = {
         ...createPageContext(),
         canonicalPath: '/c/test/%20/42',
         params: {0: 'test', 1: '42'},
       };
       // Regression test for Issue 7100.
-      element._handleImproperlyEncodedPlusRoute(params);
+      router.handleImproperlyEncodedPlusRoute(params);
       assert.isTrue(redirectStub.calledOnce);
       assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42');
 
-      sinon.stub(element, '_getHashFromCanonicalPath').returns('foo');
-      element._handleImproperlyEncodedPlusRoute(params);
+      sinon.stub(router, 'getHashFromCanonicalPath').returns('foo');
+      router.handleImproperlyEncodedPlusRoute(params);
       assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42#foo');
     });
 
-    test('_handleQueryRoute', () => {
+    test('handleQueryRoute', () => {
       const data: PageContextWithQueryMap = {
         ...createPageContext(),
         params: {0: 'project:foo/bar/baz'},
       };
-      assertDataToParams(data, '_handleQueryRoute', {
+      assertDataToParams(data, 'handleQueryRoute', {
         view: GerritNav.View.SEARCH,
         query: 'project:foo/bar/baz',
         offset: undefined,
@@ -809,35 +801,35 @@
 
       data.params[1] = '123';
       data.params[2] = '123';
-      assertDataToParams(data, '_handleQueryRoute', {
+      assertDataToParams(data, 'handleQueryRoute', {
         view: GerritNav.View.SEARCH,
         query: 'project:foo/bar/baz',
         offset: '123',
       });
     });
 
-    test('_handleQueryLegacySuffixRoute', () => {
+    test('handleQueryLegacySuffixRoute', () => {
       const params = {...createPageContext(), path: '/q/foo+bar,n,z'};
-      element._handleQueryLegacySuffixRoute(params);
+      router.handleQueryLegacySuffixRoute(params);
       assert.isTrue(redirectStub.calledOnce);
       assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
     });
 
-    test('_handleChangeIdQueryRoute', () => {
+    test('handleChangeIdQueryRoute', () => {
       const data = {
         ...createPageContext(),
         params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
       };
-      assertDataToParams(data, '_handleChangeIdQueryRoute', {
+      assertDataToParams(data, 'handleChangeIdQueryRoute', {
         view: GerritNav.View.SEARCH,
         query: 'I0123456789abcdef0123456789abcdef01234567',
       });
     });
 
-    suite('_handleRegisterRoute', () => {
+    suite('handleRegisterRoute', () => {
       test('happy path', () => {
         const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
-        element._handleRegisterRoute(ctx);
+        router.handleRegisterRoute(ctx);
         assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
         assert.isTrue(setParamsStub.calledOnce);
         assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
@@ -845,7 +837,7 @@
 
       test('no param', () => {
         const ctx = createPageContext();
-        element._handleRegisterRoute(ctx);
+        router.handleRegisterRoute(ctx);
         assert.isTrue(redirectStub.calledWithExactly('/'));
         assert.isTrue(setParamsStub.calledOnce);
         assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
@@ -853,18 +845,18 @@
 
       test('prevent redirect', () => {
         const ctx = {...createPageContext(), params: {0: '/register'}};
-        element._handleRegisterRoute(ctx);
+        router.handleRegisterRoute(ctx);
         assert.isTrue(redirectStub.calledWithExactly('/'));
         assert.isTrue(setParamsStub.calledOnce);
         assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
       });
     });
 
-    suite('_handleRootRoute', () => {
+    suite('handleRootRoute', () => {
       test('closes for closeAfterLogin', () => {
         const data = {...createPageContext(), querystring: 'closeAfterLogin'};
         const closeStub = sinon.stub(window, 'close');
-        const result = element._handleRootRoute(data);
+        const result = router.handleRootRoute(data);
         assert.isNotOk(result);
         assert.isTrue(closeStub.called);
         assert.isFalse(redirectStub.called);
@@ -872,7 +864,7 @@
 
       test('redirects to dashboard if logged in', () => {
         const data = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = element._handleRootRoute(data);
+        const result = router.handleRootRoute(data);
         assert.isOk(result);
         return result!.then(() => {
           assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
@@ -882,7 +874,7 @@
       test('redirects to open changes if not logged in', () => {
         stubRestApi('getLoggedIn').returns(Promise.resolve(false));
         const data = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = element._handleRootRoute(data);
+        const result = router.handleRootRoute(data);
         assert.isOk(result);
         return result!.then(() => {
           assert.isTrue(
@@ -898,7 +890,7 @@
             canonicalPath: '/#/foo/bar/baz',
             hash: '/foo/bar/baz',
           };
-          const result = element._handleRootRoute(data);
+          const result = router.handleRootRoute(data);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
@@ -910,7 +902,7 @@
             canonicalPath: '/#foo/bar/baz',
             hash: 'foo/bar/baz',
           };
-          const result = element._handleRootRoute(data);
+          const result = router.handleRootRoute(data);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
@@ -922,7 +914,7 @@
             canonicalPath: '/#/foo/bar/+/123/4',
             hash: '/foo/bar/ /123/4',
           };
-          const result = element._handleRootRoute(data);
+          const result = router.handleRootRoute(data);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
@@ -935,7 +927,7 @@
             hash: '/foo/bar',
           };
           stubBaseUrl('/baz');
-          const result = element._handleRootRoute(data);
+          const result = router.handleRootRoute(data);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
@@ -947,7 +939,7 @@
             canonicalPath: '/#/VE/foo/bar',
             hash: '/VE/foo/bar',
           };
-          const result = element._handleRootRoute(data);
+          const result = router.handleRootRoute(data);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
@@ -959,7 +951,7 @@
             canonicalPath: '/#/foo/bar#baz',
             hash: '/foo/bar',
           };
-          const result = element._handleRootRoute(data);
+          const result = router.handleRootRoute(data);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
@@ -967,11 +959,11 @@
       });
     });
 
-    suite('_handleDashboardRoute', () => {
+    suite('handleDashboardRoute', () => {
       let redirectToLoginStub: sinon.SinonStub;
 
       setup(() => {
-        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
+        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       });
 
       test('own dashboard but signed out redirects to login', () => {
@@ -981,7 +973,7 @@
           canonicalPath: '/dashboard/',
           params: {0: 'seLF'},
         };
-        return element._handleDashboardRoute(data).then(() => {
+        return router.handleDashboardRoute(data).then(() => {
           assert.isTrue(redirectToLoginStub.calledOnce);
           assert.isFalse(redirectStub.called);
           assert.isFalse(setParamsStub.called);
@@ -995,7 +987,7 @@
           canonicalPath: '/dashboard/',
           params: {0: 'foo'},
         };
-        return element._handleDashboardRoute(data).then(() => {
+        return router.handleDashboardRoute(data).then(() => {
           assert.isFalse(redirectToLoginStub.called);
           assert.isFalse(setParamsStub.called);
           assert.isTrue(redirectStub.calledOnce);
@@ -1009,7 +1001,7 @@
           canonicalPath: '/dashboard/',
           params: {0: 'foo'},
         };
-        return element._handleDashboardRoute(data).then(() => {
+        return router.handleDashboardRoute(data).then(() => {
           assert.isFalse(redirectToLoginStub.called);
           assert.isFalse(redirectStub.called);
           assert.isTrue(setParamsStub.calledOnce);
@@ -1021,11 +1013,11 @@
       });
     });
 
-    suite('_handleCustomDashboardRoute', () => {
+    suite('handleCustomDashboardRoute', () => {
       let redirectToLoginStub: sinon.SinonStub;
 
       setup(() => {
-        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
+        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       });
 
       test('no user specified', () => {
@@ -1034,7 +1026,7 @@
           canonicalPath: '/dashboard/',
           params: {0: ''},
         };
-        return element._handleCustomDashboardRoute(data, '').then(() => {
+        return router.handleCustomDashboardRoute(data, '').then(() => {
           assert.isFalse(setParamsStub.called);
           assert.isTrue(redirectStub.called);
           assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
@@ -1047,8 +1039,8 @@
           canonicalPath: '/dashboard/',
           params: {0: ''},
         };
-        return element
-          ._handleCustomDashboardRoute(data, '?a=b&c&d=e')
+        return router
+          .handleCustomDashboardRoute(data, '?a=b&c&d=e')
           .then(() => {
             assert.isFalse(redirectStub.called);
             assert.isTrue(setParamsStub.calledOnce);
@@ -1070,8 +1062,8 @@
           canonicalPath: '/dashboard/',
           params: {0: ''},
         };
-        return element
-          ._handleCustomDashboardRoute(data, '?a=b&c&d=&=e&title=t')
+        return router
+          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&title=t')
           .then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(redirectStub.called);
@@ -1091,8 +1083,8 @@
           canonicalPath: '/dashboard/',
           params: {0: ''},
         };
-        return element
-          ._handleCustomDashboardRoute(data, '?a=b&c&d=&=e&foreach=is:open')
+        return router
+          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&foreach=is:open')
           .then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(redirectStub.called);
@@ -1108,34 +1100,34 @@
     });
 
     suite('group routes', () => {
-      test('_handleGroupInfoRoute', () => {
+      test('handleGroupInfoRoute', () => {
         const data = {...createPageContext(), params: {0: '1234'}};
-        element._handleGroupInfoRoute(data);
+        router.handleGroupInfoRoute(data);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
       });
 
-      test('_handleGroupAuditLogRoute', () => {
+      test('handleGroupAuditLogRoute', () => {
         const data = {...createPageContext(), params: {0: '1234'}};
-        assertDataToParams(data, '_handleGroupAuditLogRoute', {
+        assertDataToParams(data, 'handleGroupAuditLogRoute', {
           view: GerritView.GROUP,
           detail: GroupDetailView.LOG,
           groupId: '1234' as GroupId,
         });
       });
 
-      test('_handleGroupMembersRoute', () => {
+      test('handleGroupMembersRoute', () => {
         const data = {...createPageContext(), params: {0: '1234'}};
-        assertDataToParams(data, '_handleGroupMembersRoute', {
+        assertDataToParams(data, 'handleGroupMembersRoute', {
           view: GerritView.GROUP,
           detail: GroupDetailView.MEMBERS,
           groupId: '1234' as GroupId,
         });
       });
 
-      test('_handleGroupListOffsetRoute', () => {
+      test('handleGroupListOffsetRoute', () => {
         const data = createPageContext();
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+        assertDataToParams(data, 'handleGroupListOffsetRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-admin-group-list',
           offset: 0,
@@ -1144,7 +1136,7 @@
         });
 
         data.params[1] = '42';
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+        assertDataToParams(data, 'handleGroupListOffsetRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-admin-group-list',
           offset: '42',
@@ -1153,7 +1145,7 @@
         });
 
         data.hash = 'create';
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+        assertDataToParams(data, 'handleGroupListOffsetRoute', {
           view: GerritNav.View.ADMIN,
           adminView: 'gr-admin-group-list',
           offset: '42',
@@ -1162,12 +1154,12 @@
         });
       });
 
-      test('_handleGroupListFilterOffsetRoute', () => {
+      test('handleGroupListFilterOffsetRoute', () => {
         const data = {
           ...createPageContext(),
           params: {filter: 'foo', offset: '42'},
         };
-        assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
+        assertDataToParams(data, 'handleGroupListFilterOffsetRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-admin-group-list',
           offset: '42',
@@ -1175,18 +1167,18 @@
         });
       });
 
-      test('_handleGroupListFilterRoute', () => {
+      test('handleGroupListFilterRoute', () => {
         const data = {...createPageContext(), params: {filter: 'foo'}};
-        assertDataToParams(data, '_handleGroupListFilterRoute', {
+        assertDataToParams(data, 'handleGroupListFilterRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-admin-group-list',
           filter: 'foo',
         });
       });
 
-      test('_handleGroupRoute', () => {
+      test('handleGroupRoute', () => {
         const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, '_handleGroupRoute', {
+        assertDataToParams(data, 'handleGroupRoute', {
           view: GerritView.GROUP,
           groupId: '4321' as GroupId,
         });
@@ -1194,23 +1186,23 @@
     });
 
     suite('repo routes', () => {
-      test('_handleProjectsOldRoute', () => {
+      test('handleProjectsOldRoute', () => {
         const data = {...createPageContext(), params: {}};
-        element._handleProjectsOldRoute(data);
+        router.handleProjectsOldRoute(data);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
       });
 
-      test('_handleProjectsOldRoute test', () => {
+      test('handleProjectsOldRoute test', () => {
         const data = {...createPageContext(), params: {1: 'test'}};
-        element._handleProjectsOldRoute(data);
+        router.handleProjectsOldRoute(data);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
       });
 
-      test('_handleProjectsOldRoute test,branches', () => {
+      test('handleProjectsOldRoute test,branches', () => {
         const data = {...createPageContext(), params: {1: 'test,branches'}};
-        element._handleProjectsOldRoute(data);
+        router.handleProjectsOldRoute(data);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(
           redirectStub.lastCall.args[0],
@@ -1218,9 +1210,9 @@
         );
       });
 
-      test('_handleRepoRoute', () => {
+      test('handleRepoRoute', () => {
         const data = {...createPageContext(), path: '/admin/repos/test'};
-        element._handleRepoRoute(data);
+        router.handleRepoRoute(data);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(
           redirectStub.lastCall.args[0],
@@ -1228,27 +1220,27 @@
         );
       });
 
-      test('_handleRepoGeneralRoute', () => {
+      test('handleRepoGeneralRoute', () => {
         const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, '_handleRepoGeneralRoute', {
+        assertDataToParams(data, 'handleRepoGeneralRoute', {
           view: GerritView.REPO,
           detail: GerritNav.RepoDetailView.GENERAL,
           repo: '4321' as RepoName,
         });
       });
 
-      test('_handleRepoCommandsRoute', () => {
+      test('handleRepoCommandsRoute', () => {
         const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, '_handleRepoCommandsRoute', {
+        assertDataToParams(data, 'handleRepoCommandsRoute', {
           view: GerritView.REPO,
           detail: GerritNav.RepoDetailView.COMMANDS,
           repo: '4321' as RepoName,
         });
       });
 
-      test('_handleRepoAccessRoute', () => {
+      test('handleRepoAccessRoute', () => {
         const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, '_handleRepoAccessRoute', {
+        assertDataToParams(data, 'handleRepoAccessRoute', {
           view: GerritView.REPO,
           detail: GerritNav.RepoDetailView.ACCESS,
           repo: '4321' as RepoName,
@@ -1256,12 +1248,12 @@
       });
 
       suite('branch list routes', () => {
-        test('_handleBranchListOffsetRoute', () => {
+        test('handleBranchListOffsetRoute', () => {
           const data: PageContextWithQueryMap = {
             ...createPageContext(),
             params: {0: '4321'},
           };
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+          assertDataToParams(data, 'handleBranchListOffsetRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
@@ -1270,7 +1262,7 @@
           });
 
           data.params[2] = '42';
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+          assertDataToParams(data, 'handleBranchListOffsetRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
@@ -1279,12 +1271,12 @@
           });
         });
 
-        test('_handleBranchListFilterOffsetRoute', () => {
+        test('handleBranchListFilterOffsetRoute', () => {
           const data = {
             ...createPageContext(),
             params: {repo: '4321', filter: 'foo', offset: '42'},
           };
-          assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
+          assertDataToParams(data, 'handleBranchListFilterOffsetRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
@@ -1293,12 +1285,12 @@
           });
         });
 
-        test('_handleBranchListFilterRoute', () => {
+        test('handleBranchListFilterRoute', () => {
           const data = {
             ...createPageContext(),
             params: {repo: '4321', filter: 'foo'},
           };
-          assertDataToParams(data, '_handleBranchListFilterRoute', {
+          assertDataToParams(data, 'handleBranchListFilterRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
@@ -1308,9 +1300,9 @@
       });
 
       suite('tag list routes', () => {
-        test('_handleTagListOffsetRoute', () => {
+        test('handleTagListOffsetRoute', () => {
           const data = {...createPageContext(), params: {0: '4321'}};
-          assertDataToParams(data, '_handleTagListOffsetRoute', {
+          assertDataToParams(data, 'handleTagListOffsetRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.TAGS,
             repo: '4321' as RepoName,
@@ -1319,12 +1311,12 @@
           });
         });
 
-        test('_handleTagListFilterOffsetRoute', () => {
+        test('handleTagListFilterOffsetRoute', () => {
           const data = {
             ...createPageContext(),
             params: {repo: '4321', filter: 'foo', offset: '42'},
           };
-          assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
+          assertDataToParams(data, 'handleTagListFilterOffsetRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.TAGS,
             repo: '4321' as RepoName,
@@ -1333,12 +1325,12 @@
           });
         });
 
-        test('_handleTagListFilterRoute', () => {
+        test('handleTagListFilterRoute', () => {
           const data: PageContextWithQueryMap = {
             ...createPageContext(),
             params: {repo: '4321'},
           };
-          assertDataToParams(data, '_handleTagListFilterRoute', {
+          assertDataToParams(data, 'handleTagListFilterRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.TAGS,
             repo: '4321' as RepoName,
@@ -1346,7 +1338,7 @@
           });
 
           data.params.filter = 'foo';
-          assertDataToParams(data, '_handleTagListFilterRoute', {
+          assertDataToParams(data, 'handleTagListFilterRoute', {
             view: GerritView.REPO,
             detail: GerritNav.RepoDetailView.TAGS,
             repo: '4321' as RepoName,
@@ -1356,9 +1348,9 @@
       });
 
       suite('repo list routes', () => {
-        test('_handleRepoListOffsetRoute', () => {
+        test('handleRepoListOffsetRoute', () => {
           const data = createPageContext();
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+          assertDataToParams(data, 'handleRepoListOffsetRoute', {
             view: GerritView.ADMIN,
             adminView: 'gr-repo-list',
             offset: 0,
@@ -1367,7 +1359,7 @@
           });
 
           data.params[1] = '42';
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+          assertDataToParams(data, 'handleRepoListOffsetRoute', {
             view: GerritView.ADMIN,
             adminView: 'gr-repo-list',
             offset: '42',
@@ -1376,7 +1368,7 @@
           });
 
           data.hash = 'create';
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+          assertDataToParams(data, 'handleRepoListOffsetRoute', {
             view: GerritView.ADMIN,
             adminView: 'gr-repo-list',
             offset: '42',
@@ -1385,12 +1377,12 @@
           });
         });
 
-        test('_handleRepoListFilterOffsetRoute', () => {
+        test('handleRepoListFilterOffsetRoute', () => {
           const data = {
             ...createPageContext(),
             params: {filter: 'foo', offset: '42'},
           };
-          assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
+          assertDataToParams(data, 'handleRepoListFilterOffsetRoute', {
             view: GerritView.ADMIN,
             adminView: 'gr-repo-list',
             offset: '42',
@@ -1398,16 +1390,16 @@
           });
         });
 
-        test('_handleRepoListFilterRoute', () => {
+        test('handleRepoListFilterRoute', () => {
           const data = createPageContext();
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
+          assertDataToParams(data, 'handleRepoListFilterRoute', {
             view: GerritView.ADMIN,
             adminView: 'gr-repo-list',
             filter: null,
           });
 
           data.params.filter = 'foo';
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
+          assertDataToParams(data, 'handleRepoListFilterRoute', {
             view: GerritView.ADMIN,
             adminView: 'gr-repo-list',
             filter: 'foo',
@@ -1417,9 +1409,9 @@
     });
 
     suite('plugin routes', () => {
-      test('_handlePluginListOffsetRoute', () => {
+      test('handlePluginListOffsetRoute', () => {
         const data = createPageContext();
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+        assertDataToParams(data, 'handlePluginListOffsetRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-plugin-list',
           offset: 0,
@@ -1427,7 +1419,7 @@
         });
 
         data.params[1] = '42';
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+        assertDataToParams(data, 'handlePluginListOffsetRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-plugin-list',
           offset: '42',
@@ -1435,12 +1427,12 @@
         });
       });
 
-      test('_handlePluginListFilterOffsetRoute', () => {
+      test('handlePluginListFilterOffsetRoute', () => {
         const data = {
           ...createPageContext(),
           params: {filter: 'foo', offset: '42'},
         };
-        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
+        assertDataToParams(data, 'handlePluginListFilterOffsetRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-plugin-list',
           offset: '42',
@@ -1448,25 +1440,25 @@
         });
       });
 
-      test('_handlePluginListFilterRoute', () => {
+      test('handlePluginListFilterRoute', () => {
         const data = createPageContext();
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
+        assertDataToParams(data, 'handlePluginListFilterRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-plugin-list',
           filter: null,
         });
 
         data.params.filter = 'foo';
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
+        assertDataToParams(data, 'handlePluginListFilterRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-plugin-list',
           filter: 'foo',
         });
       });
 
-      test('_handlePluginListRoute', () => {
+      test('handlePluginListRoute', () => {
         const data = createPageContext();
-        assertDataToParams(data, '_handlePluginListRoute', {
+        assertDataToParams(data, 'handlePluginListRoute', {
           view: GerritView.ADMIN,
           adminView: 'gr-plugin-list',
         });
@@ -1474,14 +1466,14 @@
     });
 
     suite('change/diff routes', () => {
-      test('_handleChangeNumberLegacyRoute', () => {
+      test('handleChangeNumberLegacyRoute', () => {
         const data = {...createPageContext(), params: {0: '12345'}};
-        element._handleChangeNumberLegacyRoute(data);
+        router.handleChangeNumberLegacyRoute(data);
         assert.isTrue(redirectStub.calledOnce);
         assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
       });
 
-      test('_handleChangeLegacyRoute', async () => {
+      test('handleChangeLegacyRoute', async () => {
         stubRestApi('getFromProjectLookup').returns(
           Promise.resolve('project' as RepoName)
         );
@@ -1489,32 +1481,32 @@
           ...createPageContext(),
           params: {0: '1234', 1: 'comment/6789'},
         };
-        element._handleChangeLegacyRoute(ctx);
+        router.handleChangeLegacyRoute(ctx);
         await flush();
         assert.isTrue(
           redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
         );
       });
 
-      test('_handleLegacyLinenum w/ @321', () => {
+      test('handleLegacyLinenum w/ @321', () => {
         const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@321'};
-        element._handleLegacyLinenum(ctx);
+        router.handleLegacyLinenum(ctx);
         assert.isTrue(redirectStub.calledOnce);
         assert.isTrue(
           redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#321')
         );
       });
 
-      test('_handleLegacyLinenum w/ @b123', () => {
+      test('handleLegacyLinenum w/ @b123', () => {
         const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@b123'};
-        element._handleLegacyLinenum(ctx);
+        router.handleLegacyLinenum(ctx);
         assert.isTrue(redirectStub.calledOnce);
         assert.isTrue(
           redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#b123')
         );
       });
 
-      suite('_handleChangeRoute', () => {
+      suite('handleChangeRoute', () => {
         let normalizeRangeStub: sinon.SinonStub;
 
         function makeParams(
@@ -1536,18 +1528,15 @@
         }
 
         setup(() => {
-          normalizeRangeStub = sinon.stub(
-            element,
-            '_normalizePatchRangeParams'
-          );
+          normalizeRangeStub = sinon.stub(router, 'normalizePatchRangeParams');
           stubRestApi('setInProjectLookup');
         });
 
         test('needs redirect', () => {
           normalizeRangeStub.returns(true);
-          sinon.stub(element, '_generateUrl').returns('foo');
+          sinon.stub(router, 'generateUrl').returns('foo');
           const ctx = makeParams('', '');
-          element._handleChangeRoute(ctx);
+          router.handleChangeRoute(ctx);
           assert.isTrue(normalizeRangeStub.called);
           assert.isFalse(setParamsStub.called);
           assert.isTrue(redirectStub.calledOnce);
@@ -1556,9 +1545,9 @@
 
         test('change view', () => {
           normalizeRangeStub.returns(false);
-          sinon.stub(element, '_generateUrl').returns('foo');
+          sinon.stub(router, 'generateUrl').returns('foo');
           const ctx = makeParams('', '');
-          assertDataToParams(ctx, '_handleChangeRoute', {
+          assertDataToParams(ctx, 'handleChangeRoute', {
             view: GerritView.CHANGE,
             project: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
@@ -1571,13 +1560,13 @@
 
         test('params', () => {
           normalizeRangeStub.returns(false);
-          sinon.stub(element, '_generateUrl').returns('foo');
+          sinon.stub(router, 'generateUrl').returns('foo');
           const ctx = makeParams('', '');
           ctx.queryMap.set('tab', 'checks');
           ctx.queryMap.set('filter', 'fff');
           ctx.queryMap.set('select', 'sss');
           ctx.queryMap.set('attempt', '1');
-          assertDataToParams(ctx, '_handleChangeRoute', {
+          assertDataToParams(ctx, 'handleChangeRoute', {
             view: GerritView.CHANGE,
             project: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
@@ -1591,7 +1580,7 @@
         });
       });
 
-      suite('_handleDiffRoute', () => {
+      suite('handleDiffRoute', () => {
         let normalizeRangeStub: sinon.SinonStub;
 
         function makeParams(
@@ -1616,18 +1605,15 @@
         }
 
         setup(() => {
-          normalizeRangeStub = sinon.stub(
-            element,
-            '_normalizePatchRangeParams'
-          );
+          normalizeRangeStub = sinon.stub(router, 'normalizePatchRangeParams');
           stubRestApi('setInProjectLookup');
         });
 
         test('needs redirect', () => {
           normalizeRangeStub.returns(true);
-          sinon.stub(element, '_generateUrl').returns('foo');
+          sinon.stub(router, 'generateUrl').returns('foo');
           const ctx = makeParams('', '');
-          element._handleDiffRoute(ctx);
+          router.handleDiffRoute(ctx);
           assert.isTrue(normalizeRangeStub.called);
           assert.isFalse(setParamsStub.called);
           assert.isTrue(redirectStub.calledOnce);
@@ -1636,9 +1622,9 @@
 
         test('diff view', () => {
           normalizeRangeStub.returns(false);
-          sinon.stub(element, '_generateUrl').returns('foo');
+          sinon.stub(router, 'generateUrl').returns('foo');
           const ctx = makeParams('foo/bar/baz', 'b44');
-          assertDataToParams(ctx, '_handleDiffRoute', {
+          assertDataToParams(ctx, 'handleDiffRoute', {
             view: GerritView.DIFF,
             project: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
@@ -1662,7 +1648,7 @@
           ]);
           assertDataToParams(
             {params: groups!.slice(1)} as any,
-            '_handleCommentRoute',
+            'handleCommentRoute',
             {
               project: 'gerrit' as RepoName,
               changeNum: 264833 as NumericChangeId,
@@ -1683,7 +1669,7 @@
           ]);
           assertDataToParams(
             {params: groups!.slice(1)} as any,
-            '_handleCommentsRoute',
+            'handleCommentsRoute',
             {
               project: 'gerrit' as RepoName,
               changeNum: 264833 as NumericChangeId,
@@ -1694,10 +1680,10 @@
         });
       });
 
-      test('_handleDiffEditRoute', () => {
+      test('handleDiffEditRoute', () => {
         const normalizeRangeSpy = sinon.spy(
-          element,
-          '_normalizePatchRangeParams'
+          router,
+          'normalizePatchRangeParams'
         );
         stubRestApi('setInProjectLookup');
         const ctx = {
@@ -1719,7 +1705,7 @@
           lineNum: '',
         };
 
-        element._handleDiffEditRoute(ctx);
+        router.handleDiffEditRoute(ctx);
         assert.isFalse(redirectStub.called);
         assert.isTrue(normalizeRangeSpy.calledOnce);
         assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
@@ -1727,10 +1713,10 @@
         assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
       });
 
-      test('_handleDiffEditRoute with lineNum', () => {
+      test('handleDiffEditRoute with lineNum', () => {
         const normalizeRangeSpy = sinon.spy(
-          element,
-          '_normalizePatchRangeParams'
+          router,
+          'normalizePatchRangeParams'
         );
         stubRestApi('setInProjectLookup');
         const ctx = {
@@ -1752,7 +1738,7 @@
           lineNum: '4',
         };
 
-        element._handleDiffEditRoute(ctx);
+        router.handleDiffEditRoute(ctx);
         assert.isFalse(redirectStub.called);
         assert.isTrue(normalizeRangeSpy.calledOnce);
         assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
@@ -1760,10 +1746,10 @@
         assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
       });
 
-      test('_handleChangeEditRoute', () => {
+      test('handleChangeEditRoute', () => {
         const normalizeRangeSpy = sinon.spy(
-          element,
-          '_normalizePatchRangeParams'
+          router,
+          'normalizePatchRangeParams'
         );
         stubRestApi('setInProjectLookup');
         const ctx = {
@@ -1784,7 +1770,7 @@
           tab: '',
         };
 
-        element._handleChangeEditRoute(ctx);
+        router.handleChangeEditRoute(ctx);
         assert.isFalse(redirectStub.called);
         assert.isTrue(normalizeRangeSpy.calledOnce);
         assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
@@ -1793,9 +1779,9 @@
       });
     });
 
-    test('_handlePluginScreen', () => {
+    test('handlePluginScreen', () => {
       const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
-      assertDataToParams(ctx, '_handlePluginScreen', {
+      assertDataToParams(ctx, 'handlePluginScreen', {
         view: GerritNav.View.PLUGIN_SCREEN,
         plugin: 'foo',
         screen: 'bar',
@@ -1804,30 +1790,30 @@
     });
   });
 
-  suite('_parseQueryString', () => {
+  suite('parseQueryString', () => {
     test('empty queries', () => {
-      assert.deepEqual(element._parseQueryString(''), []);
-      assert.deepEqual(element._parseQueryString('?'), []);
-      assert.deepEqual(element._parseQueryString('??'), []);
-      assert.deepEqual(element._parseQueryString('&&&'), []);
+      assert.deepEqual(router.parseQueryString(''), []);
+      assert.deepEqual(router.parseQueryString('?'), []);
+      assert.deepEqual(router.parseQueryString('??'), []);
+      assert.deepEqual(router.parseQueryString('&&&'), []);
     });
 
     test('url decoding', () => {
-      assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
-      assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
+      assert.deepEqual(router.parseQueryString('+'), [[' ', '']]);
+      assert.deepEqual(router.parseQueryString('???+%3d+'), [[' = ', '']]);
       assert.deepEqual(
-        element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
+        router.parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
         [['name', 'value']]
       );
     });
 
     test('multiple parameters', () => {
-      assert.deepEqual(element._parseQueryString('a=b&c=d&e=f'), [
+      assert.deepEqual(router.parseQueryString('a=b&c=d&e=f'), [
         ['a', 'b'],
         ['c', 'd'],
         ['e', 'f'],
       ]);
-      assert.deepEqual(element._parseQueryString('&a=b&&&e=f&c'), [
+      assert.deepEqual(router.parseQueryString('&a=b&&&e=f&c'), [
         ['a', 'b'],
         ['e', 'f'],
         ['c', ''],
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index a97ae96..dfdb187 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -19,7 +19,7 @@
 import './gr-search-bar';
 import {GrSearchBar} from './gr-search-bar';
 import '../../../scripts/util';
-import {mockPromise} from '../../../test/test-utils';
+import {mockPromise, waitUntil} from '../../../test/test-utils';
 import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {
@@ -93,7 +93,7 @@
       null,
       'enter'
     );
-    assert.isTrue(blurSpy.called);
+    await waitUntil(() => blurSpy.called);
   });
 
   test('empty search query does not trigger nav', async () => {
@@ -138,7 +138,7 @@
       null,
       'enter'
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
   test('undefined predicate query triggers nav', async () => {
@@ -153,7 +153,7 @@
       null,
       'enter'
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
   test('empty undefined predicate query triggers nav', async () => {
@@ -168,7 +168,7 @@
       null,
       'enter'
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
   test('keyboard shortcuts', async () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 567e1bb..c264978 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -1,28 +1,14 @@
 /**
  * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-icon/iron-icon';
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../embed/diff/gr-diff/gr-diff';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-apply-fix-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {
   NumericChangeId,
   EditPatchSetNum,
@@ -41,13 +27,9 @@
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-
-export interface GrApplyFixDialog {
-  $: {
-    applyFixOverlay: GrOverlay;
-    nextFix: GrButton;
-  };
-}
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 interface FilePreview {
   filepath: string;
@@ -55,10 +37,12 @@
 }
 
 @customElement('gr-apply-fix-dialog')
-export class GrApplyFixDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrApplyFixDialog extends LitElement {
+  @query('#applyFixOverlay')
+  applyFixOverlay?: GrOverlay;
+
+  @query('#nextFix')
+  nextFix?: GrButton;
 
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
@@ -66,52 +50,142 @@
   @property({type: Object})
   change?: ParsedChangeInfo;
 
-  @property({type: String})
+  @property({type: Number})
   changeNum?: NumericChangeId;
 
-  @property({type: Number})
-  _patchNum?: PatchSetNum;
+  @state()
+  patchNum?: PatchSetNum;
 
-  @property({type: String})
-  _robotId?: RobotId;
+  @state()
+  robotId?: RobotId;
 
-  @property({type: Object})
-  _currentFix?: FixSuggestionInfo;
+  @state()
+  currentFix?: FixSuggestionInfo;
 
-  @property({type: Array})
-  _currentPreviews: FilePreview[] = [];
+  @state()
+  currentPreviews: FilePreview[] = [];
 
-  @property({type: Array})
-  _fixSuggestions?: FixSuggestionInfo[];
+  @state()
+  fixSuggestions?: FixSuggestionInfo[];
 
-  @property({type: Boolean})
-  _isApplyFixLoading = false;
+  @state()
+  isApplyFixLoading = false;
 
-  @property({type: Number})
-  _selectedFixIdx = 0;
+  @state()
+  selectedFixIdx = 0;
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeDisableApplyFixButton(_isApplyFixLoading, change, ' +
-      '_patchNum)',
-  })
-  _disableApplyFixButton = false;
-
-  @property({type: Array})
+  @state()
   layers: DiffLayer[] = [];
 
-  private refitOverlay?: () => void;
-
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
+    // TODO Get preferences from model.
     this.restApiService.getPreferences().then(prefs => {
       if (!prefs?.disable_token_highlighting) {
         this.layers = [new TokenHighlightLayer(this)];
       }
     });
+    this.addEventListener('diff-context-expanded', () => {
+      if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
+    });
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      gr-diff {
+        --content-width: 90vw;
+      }
+      .diffContainer {
+        padding: var(--spacing-l) 0;
+        border-bottom: 1px solid var(--border-color);
+      }
+      .file-name {
+        display: block;
+        padding: var(--spacing-s) var(--spacing-l);
+        background-color: var(--background-color-secondary);
+        border-bottom: 1px solid var(--border-color);
+      }
+      gr-button {
+        margin-left: var(--spacing-m);
+      }
+      .fix-picker {
+        display: flex;
+        align-items: center;
+        margin-right: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-overlay id="applyFixOverlay" with-backdrop="">
+        <gr-dialog
+          id="applyFixDialog"
+          .confirmLabel=${this.isApplyFixLoading ? 'Saving...' : 'Apply Fix'}
+          .confirmTooltip=${this.computeTooltip()}
+          ?disabled=${this.computeDisableApplyFixButton()}
+          @confirm=${this.handleApplyFix}
+          @cancel=${this.onCancel}
+        >
+          ${this.renderHeader()} ${this.renderMain()} ${this.renderFooter()}
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderHeader() {
+    return html`
+      <div slot="header">
+        ${this.robotId ?? ''} - ${this.currentFix?.description ?? ''}
+      </div>
+    `;
+  }
+
+  private renderMain() {
+    const items = this.currentPreviews.map(
+      item => html`
+        <div class="file-name">
+          <span>${item.filepath}</span>
+        </div>
+        <div class="diffContainer">
+          <gr-diff
+            .prefs=${this.overridePartialPrefs()}
+            .path=${item.filepath}
+            .diff=${item.preview}
+            .layers=${this.layers}
+          ></gr-diff>
+        </div>
+      `
+    );
+    return html`<div slot="main">${items}</div>`;
+  }
+
+  private renderFooter() {
+    const id = this.selectedFixIdx;
+    const fixCount = this.fixSuggestions?.length ?? 0;
+    if (fixCount < 2) return;
+    return html`
+      <div slot="footer" class="fix-picker">
+        <span>Suggested fix ${id + 1} of ${fixCount}</span>
+        <gr-button
+          id="prevFix"
+          @click=${this.onPrevFixClick}
+          ?disabled=${id === 0}
+        >
+          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+        </gr-button>
+        <gr-button
+          id="nextFix"
+          @click=${this.onNextFixClick}
+          ?disabled=${id === fixCount - 1}
+        >
+          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        </gr-button>
+      </div>
+    `;
   }
 
   /**
@@ -128,183 +202,131 @@
     if (!detail.patchNum || !comment || !isRobot(comment)) {
       return Promise.resolve();
     }
-    this._patchNum = detail.patchNum;
-    this._fixSuggestions = comment.fix_suggestions;
-    this._robotId = comment.robot_id;
-    if (!this._fixSuggestions || !this._fixSuggestions.length) {
+    this.patchNum = detail.patchNum;
+    this.fixSuggestions = comment.fix_suggestions;
+    this.robotId = comment.robot_id;
+    if (!this.fixSuggestions || !this.fixSuggestions.length) {
       return Promise.resolve();
     }
-    this._selectedFixIdx = 0;
+    this.selectedFixIdx = 0;
     const promises = [];
     promises.push(
-      this._showSelectedFixSuggestion(this._fixSuggestions[0]),
-      this.$.applyFixOverlay.open()
+      this.showSelectedFixSuggestion(this.fixSuggestions[0]),
+      this.applyFixOverlay?.open()
     );
     return Promise.all(promises).then(() => {
-      // ensures gr-overlay repositions overlay in center
-      fireEvent(this.$.applyFixOverlay, 'iron-resize');
+      if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
     });
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.refitOverlay = () => {
-      // re-center the dialog as content changed
-      fireEvent(this.$.applyFixOverlay, 'iron-resize');
-    };
-    this.addEventListener('diff-context-expanded', this.refitOverlay);
+  private showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
+    this.currentFix = fixSuggestion;
+    return this.fetchFixPreview(fixSuggestion.fix_id);
   }
 
-  override disconnectedCallback() {
-    if (this.refitOverlay) {
-      this.removeEventListener('diff-context-expanded', this.refitOverlay);
-    }
-    super.disconnectedCallback();
-  }
-
-  _showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
-    this._currentFix = fixSuggestion;
-    return this._fetchFixPreview(fixSuggestion.fix_id);
-  }
-
-  _fetchFixPreview(fixId: FixId) {
-    if (!this.changeNum || !this._patchNum) {
+  private fetchFixPreview(fixId: FixId) {
+    if (!this.changeNum || !this.patchNum) {
       return Promise.reject(
-        new Error('Both _patchNum and changeNum must be set')
+        new Error('Both patchNum and changeNum must be set')
       );
     }
     return this.restApiService
-      .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
+      .getRobotCommentFixPreview(this.changeNum, this.patchNum, fixId)
       .then(res => {
         if (res) {
-          this._currentPreviews = Object.keys(res).map(key => {
+          this.currentPreviews = Object.keys(res).map(key => {
             return {filepath: key, preview: res[key]};
           });
         }
       })
       .catch(err => {
-        this._close(false);
+        this.close(false);
         throw err;
       });
   }
 
-  hasSingleFix(_fixSuggestions?: FixSuggestionInfo[]) {
-    return (_fixSuggestions || []).length === 1;
-  }
-
-  overridePartialPrefs(prefs?: DiffPreferencesInfo) {
-    if (!prefs) return undefined;
+  private overridePartialPrefs() {
+    if (!this.prefs) return undefined;
     // generate a smaller gr-diff than fullscreen for dialog
-    return {...prefs, line_length: 50};
+    return {...this.prefs, line_length: 50};
   }
 
+  // visible for testing
   onCancel(e: Event) {
-    if (e) {
-      e.stopPropagation();
-    }
-    this._close(false);
-  }
-
-  addOneTo(_selectedFixIdx: number) {
-    return _selectedFixIdx + 1;
-  }
-
-  _onPrevFixClick(e: Event) {
     if (e) e.stopPropagation();
-    if (this._selectedFixIdx >= 1 && this._fixSuggestions) {
-      this._selectedFixIdx -= 1;
-      this._showSelectedFixSuggestion(
-        this._fixSuggestions[this._selectedFixIdx]
-      );
+    this.close(false);
+  }
+
+  // visible for testing
+  onPrevFixClick(e: Event) {
+    if (e) e.stopPropagation();
+    if (this.selectedFixIdx >= 1 && this.fixSuggestions) {
+      this.selectedFixIdx -= 1;
+      this.showSelectedFixSuggestion(this.fixSuggestions[this.selectedFixIdx]);
     }
   }
 
-  _onNextFixClick(e: Event) {
+  // visible for testing
+  onNextFixClick(e: Event) {
     if (e) e.stopPropagation();
     if (
-      this._fixSuggestions &&
-      this._selectedFixIdx < this._fixSuggestions.length
+      this.fixSuggestions &&
+      this.selectedFixIdx < this.fixSuggestions.length
     ) {
-      this._selectedFixIdx += 1;
-      this._showSelectedFixSuggestion(
-        this._fixSuggestions[this._selectedFixIdx]
-      );
+      this.selectedFixIdx += 1;
+      this.showSelectedFixSuggestion(this.fixSuggestions[this.selectedFixIdx]);
     }
   }
 
-  _noPrevFix(_selectedFixIdx: number) {
-    return _selectedFixIdx === 0;
-  }
-
-  _noNextFix(_selectedFixIdx: number, fixSuggestions?: FixSuggestionInfo[]) {
-    if (!fixSuggestions) return true;
-    return _selectedFixIdx === fixSuggestions.length - 1;
-  }
-
-  _close(fixApplied: boolean) {
-    this._currentFix = undefined;
-    this._currentPreviews = [];
-    this._isApplyFixLoading = false;
+  private close(fixApplied: boolean) {
+    this.currentFix = undefined;
+    this.currentPreviews = [];
+    this.isApplyFixLoading = false;
 
     fireCloseFixPreview(this, fixApplied);
-    this.$.applyFixOverlay.close();
+    this.applyFixOverlay?.close();
   }
 
-  _getApplyFixButtonLabel(isLoading: boolean) {
-    return isLoading ? 'Saving...' : 'Apply Fix';
-  }
-
-  _computeTooltip(change?: ParsedChangeInfo, patchNum?: PatchSetNum) {
-    if (!change || !patchNum) return '';
-    const latestPatchNum = change.revisions[change.current_revision]._number;
-    return latestPatchNum !== patchNum
+  private computeTooltip() {
+    if (!this.change || !this.patchNum) return '';
+    const currentPatchNum =
+      this.change.revisions[this.change.current_revision]._number;
+    return currentPatchNum !== this.patchNum
       ? 'Fix can only be applied to the latest patchset'
       : '';
   }
 
-  _computeDisableApplyFixButton(
-    isApplyFixLoading: boolean,
-    change?: ParsedChangeInfo,
-    patchNum?: PatchSetNum
-  ) {
-    if (!change || isApplyFixLoading === undefined || patchNum === undefined) {
-      return true;
-    }
-    const currentPatchNum = change.revisions[change.current_revision]._number;
-    if (patchNum !== currentPatchNum) {
-      return true;
-    }
-    return isApplyFixLoading;
+  private computeDisableApplyFixButton() {
+    if (!this.change || !this.patchNum) return true;
+    const currentPatchNum =
+      this.change.revisions[this.change.current_revision]._number;
+    return this.patchNum !== currentPatchNum || this.isApplyFixLoading;
   }
 
-  _handleApplyFix(e: Event) {
-    if (e) {
-      e.stopPropagation();
-    }
+  // visible for testing
+  async handleApplyFix(e: Event) {
+    if (e) e.stopPropagation();
 
     const changeNum = this.changeNum;
-    const patchNum = this._patchNum;
+    const patchNum = this.patchNum;
     const change = this.change;
-    if (!changeNum || !patchNum || !change || !this._currentFix) {
-      return Promise.reject(new Error('Not all required properties are set.'));
+    if (!changeNum || !patchNum || !change || !this.currentFix) {
+      throw new Error('Not all required properties are set.');
     }
-    this._isApplyFixLoading = true;
-    return this.restApiService
-      .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
-      .then(res => {
-        if (res && res.ok) {
-          GerritNav.navigateToChange(change, {
-            patchNum: EditPatchSetNum,
-            basePatchNum: patchNum as BasePatchSetNum,
-          });
-          this._close(true);
-        }
-        this._isApplyFixLoading = false;
+    this.isApplyFixLoading = true;
+    const res = await this.restApiService.applyFixSuggestion(
+      changeNum,
+      patchNum,
+      this.currentFix.fix_id
+    );
+    if (res && res.ok) {
+      GerritNav.navigateToChange(change, {
+        patchNum: EditPatchSetNum,
+        basePatchNum: patchNum as BasePatchSetNum,
       });
-  }
-
-  getFixDescription(currentFix?: FixSuggestionInfo) {
-    return currentFix && currentFix.description ? currentFix.description : '';
+      this.close(true);
+    }
+    this.isApplyFixLoading = false;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
deleted file mode 100644
index 0f50157..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-diff {
-      --content-width: 90vw;
-    }
-    .diffContainer {
-      padding: var(--spacing-l) 0;
-      border-bottom: 1px solid var(--border-color);
-    }
-    .file-name {
-      display: block;
-      padding: var(--spacing-s) var(--spacing-l);
-      background-color: var(--background-color-secondary);
-      border-bottom: 1px solid var(--border-color);
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    .fix-picker {
-      display: flex;
-      align-items: center;
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <gr-overlay id="applyFixOverlay" with-backdrop="">
-    <gr-dialog
-      id="applyFixDialog"
-      on-confirm="_handleApplyFix"
-      confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]"
-      disabled="[[_disableApplyFixButton]]"
-      confirm-tooltip="[[_computeTooltip(change, _patchNum)]]"
-      on-cancel="onCancel"
-    >
-      <div slot="header">[[_robotId]] - [[getFixDescription(_currentFix)]]</div>
-      <div slot="main">
-        <template is="dom-repeat" items="[[_currentPreviews]]">
-          <div class="file-name">
-            <span>[[item.filepath]]</span>
-          </div>
-          <div class="diffContainer">
-            <gr-diff
-              prefs="[[overridePartialPrefs(prefs)]]"
-              path="[[item.filepath]]"
-              diff="[[item.preview]]"
-              layers="[[layers]]"
-            ></gr-diff>
-          </div>
-        </template>
-      </div>
-      <div
-        slot="footer"
-        class="fix-picker"
-        hidden$="[[hasSingleFix(_fixSuggestions)]]"
-      >
-        <span
-          >Suggested fix [[addOneTo(_selectedFixIdx)]] of
-          [[_fixSuggestions.length]]</span
-        >
-        <gr-button
-          id="prevFix"
-          on-click="_onPrevFixClick"
-          disabled$="[[_noPrevFix(_selectedFixIdx)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-        </gr-button>
-        <gr-button
-          id="nextFix"
-          on-click="_onNextFixClick"
-          disabled$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-        </gr-button>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 9392cb9d1..2c0fe0d 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-apply-fix-dialog';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -30,6 +18,7 @@
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {Comment} from '../../../utils/comment-util';
 import {
   createFixSuggestionInfo,
   createParsedChange,
@@ -44,8 +33,7 @@
   OpenFixPreviewEventDetail,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-apply-fix-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-apply-fix-dialog tests', () => {
   let element: GrApplyFixDialog;
@@ -78,15 +66,29 @@
     );
   }
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  async function open(comment: Comment) {
+    await element.open(
+      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+        detail: {
+          patchNum: 2 as PatchSetNum,
+          comment,
+        },
+      })
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    element = await fixture<GrApplyFixDialog>(
+      html`<gr-apply-fix-dialog></gr-apply-fix-dialog>`
+    );
     const change = {
       ...createParsedChange(),
       revisions: createRevisions(2),
       current_revision: getCurrentRevision(1),
     };
     element.changeNum = change._number;
-    element._patchNum = change.revisions[change.current_revision]._number;
+    element.patchNum = change.revisions[change.current_revision]._number;
     element.change = change;
     element.prefs = {
       ...createDefaultDiffPrefs(),
@@ -94,6 +96,7 @@
       line_length: 100,
       tab_size: 4,
     };
+    await element.updateComplete;
   });
 
   suite('dialog open', () => {
@@ -156,37 +159,22 @@
           f2: diffInfo2,
         })
       );
-      sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+      sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
     });
 
     test('dialog opens fetch and sets previews', async () => {
-      await element.open(
-        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-          detail: {
-            patchNum: 2 as PatchSetNum,
-            comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-          },
-        })
-      );
-      assert.equal(element._currentFix!.fix_id, 'fix_1');
-      assert.equal(element._currentPreviews.length, 2);
-      assert.equal(element._robotId, 'robot_1' as RobotId);
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+      assert.equal(element.currentFix!.fix_id, 'fix_1');
+      assert.equal(element.currentPreviews.length, 2);
+      assert.equal(element.robotId, 'robot_1' as RobotId);
       const button = getConfirmButton();
       assert.isFalse(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
     });
 
     test('tooltip is hidden if apply fix is loading', async () => {
-      await element.open(
-        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-          detail: {
-            patchNum: 2 as PatchSetNum,
-            comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-          },
-        })
-      );
-      element._isApplyFixLoading = true;
-      await flush();
+      element.isApplyFixLoading = true;
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
@@ -198,15 +186,7 @@
         revisions: createRevisions(2),
         current_revision: getCurrentRevision(0),
       };
-      await element.open(
-        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-          detail: {
-            patchNum: 2 as PatchSetNum,
-            comment: ROBOT_COMMENT_WITH_ONE_FIX,
-          },
-        })
-      );
-      await flush();
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(
@@ -216,44 +196,63 @@
     });
   });
 
+  test('renders', async () => {
+    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    expect(element).shadowDom.to.equal(
+      /* HTML */ `
+        <gr-overlay id="applyFixOverlay" tabindex="-1" with-backdrop="">
+          <gr-dialog id="applyFixDialog" role="dialog">
+            <div slot="header">robot_1 - Fix fix_1</div>
+            <div slot="main"></div>
+            <div class="fix-picker" slot="footer">
+              <span>Suggested fix 1 of 2</span>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="prevFix"
+                role="button"
+                tabindex="-1"
+              >
+                <iron-icon icon="gr-icons:chevron-left"> </iron-icon>
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                id="nextFix"
+                role="button"
+                tabindex="0"
+              >
+                <iron-icon icon="gr-icons:chevron-right"> </iron-icon>
+              </gr-button>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `,
+      {ignoreAttributes: ['style']}
+    );
+  });
+
   test('next button state updated when suggestions changed', async () => {
     stubRestApi('getRobotCommentFixPreview').returns(Promise.resolve({}));
-    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+    sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
 
-    await element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_ONE_FIX,
-        },
-      })
-    );
-    assert.isTrue(element.$.nextFix.disabled);
-    await element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-        },
-      })
-    );
-    assert.isFalse(element.$.nextFix.disabled);
+    await open(ROBOT_COMMENT_WITH_ONE_FIX);
+    await element.updateComplete;
+    assert.notOk(element.nextFix);
+    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    assert.ok(element.nextFix);
+    assert.notOk(element.nextFix!.disabled);
   });
 
   test('preview endpoint throws error should reset dialog', async () => {
     stubRestApi('getRobotCommentFixPreview').returns(
       Promise.reject(new Error('backend error'))
     );
-    element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-        },
-      })
-    );
-    await flush();
-    assert.equal(element._currentFix, undefined);
+    try {
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    } catch (error) {
+      // expected
+    }
+    assert.equal(element.currentFix, undefined);
   });
 
   test('apply fix button should call apply, navigate to change view and fire close', async () => {
@@ -261,7 +260,7 @@
       Promise.resolve(new Response(null, {status: 200}))
     );
     const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = createFixSuggestionInfo('123');
+    element.currentFix = createFixSuggestionInfo('123');
 
     const closeFixPreviewEventSpy = sinon.spy();
     // Element is recreated after each test, removeEventListener isn't required
@@ -269,7 +268,7 @@
       EventType.CLOSE_FIX_PREVIEW,
       closeFixPreviewEventSpy
     );
-    await element._handleApplyFix(new CustomEvent('confirm'));
+    await element.handleApplyFix(new CustomEvent('confirm'));
 
     sinon.assert.calledOnceWithExactly(
       applyFixSuggestionStub,
@@ -292,8 +291,8 @@
     );
 
     // reset gr-apply-fix-dialog and close
-    assert.equal(element._currentFix, undefined);
-    assert.equal(element._currentPreviews.length, 0);
+    assert.equal(element.currentFix, undefined);
+    assert.equal(element.currentPreviews.length, 0);
   });
 
   test('should not navigate to change view if incorect reponse', async () => {
@@ -301,9 +300,9 @@
       Promise.resolve(new Response(null, {status: 500}))
     );
     const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = createFixSuggestionInfo('fix_123');
+    element.currentFix = createFixSuggestionInfo('fix_123');
 
-    await element._handleApplyFix(new CustomEvent('confirm'));
+    await element.handleApplyFix(new CustomEvent('confirm'));
     sinon.assert.calledWithExactly(
       applyFixSuggestionStub,
       element.change!._number,
@@ -312,24 +311,17 @@
     );
     assert.isTrue(navigateToChangeStub.notCalled);
 
-    assert.equal(element._isApplyFixLoading, false);
+    assert.equal(element.isApplyFixLoading, false);
   });
 
   test('select fix forward and back of multiple suggested fixes', async () => {
-    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+    sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
 
-    await element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-        },
-      })
-    );
-    element._onNextFixClick(new CustomEvent('click'));
-    assert.equal(element._currentFix!.fix_id, 'fix_2');
-    element._onPrevFixClick(new CustomEvent('click'));
-    assert.equal(element._currentFix!.fix_id, 'fix_1');
+    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    element.onNextFixClick(new CustomEvent('click'));
+    assert.equal(element.currentFix!.fix_id, 'fix_2');
+    element.onPrevFixClick(new CustomEvent('click'));
+    assert.equal(element.currentFix!.fix_id, 'fix_1');
   });
 
   test('server-error should throw for failed apply call', async () => {
@@ -337,7 +329,7 @@
       Promise.reject(new Error('backend error'))
     );
     const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = createFixSuggestionInfo('fix_123');
+    element.currentFix = createFixSuggestionInfo('fix_123');
 
     const closeFixPreviewEventSpy = sinon.spy();
     // Element is recreated after each test, removeEventListener isn't required
@@ -347,7 +339,7 @@
     );
 
     let expectedError;
-    await element._handleApplyFix(new CustomEvent('click')).catch(e => {
+    await element.handleApplyFix(new CustomEvent('click')).catch(e => {
       expectedError = e;
     });
     assert.isOk(expectedError);
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 cb4d1e6..eb6d071 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
@@ -46,7 +46,6 @@
   CommitRange,
   CoverageRange,
   DiffLayer,
-  DiffLayerListener,
   PatchSetFile,
 } from '../../../types/types';
 import {
@@ -70,7 +69,6 @@
   CreateCommentEventDetail,
   GrDiff,
 } from '../../../embed/diff/gr-diff/gr-diff';
-import {GrSyntaxLayer} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer';
 import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
@@ -101,6 +99,7 @@
 import {deepEqual} from '../../../utils/deep-util';
 import {Category} from '../../../api/checks';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {CODE_MAX_LINES} from '../../../services/highlight/highlight-service';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -108,12 +107,6 @@
 const EVENT_ZERO_REBASE = 'rebase-percent-zero';
 const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
-// Disable syntax highlighting if the overall diff is too large.
-const SYNTAX_MAX_DIFF_LENGTH = 20000;
-
-// 120 lines is good enough threshold for full-sized window viewport
-const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
-
 function isImageDiff(diff?: DiffInfo) {
   if (!diff) return false;
 
@@ -242,7 +235,7 @@
   @property({type: Object})
   _revisionImage: Base64ImageFile | null = null;
 
-  @property({type: Object, notify: true, observer: 'diffChanged'})
+  @property({type: Object, notify: true})
   diff?: DiffInfo;
 
   @property({type: Object})
@@ -284,8 +277,6 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly flagService = getAppContext().flagsService;
-
   private readonly reporting = getAppContext().reportingService;
 
   private readonly flags = getAppContext().flagsService;
@@ -294,7 +285,7 @@
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly syntaxLayer: GrSyntaxLayer | GrSyntaxLayerWorker;
+  private readonly syntaxLayer: GrSyntaxLayerWorker;
 
   private checksSubscription?: Subscription;
 
@@ -302,12 +293,7 @@
 
   constructor() {
     super();
-    const useWorker = this.flags.isEnabled(
-      KnownExperimentId.SYNTAX_LAYER_WORKER
-    );
-    this.syntaxLayer = useWorker
-      ? new GrSyntaxLayerWorker()
-      : new GrSyntaxLayer();
+    this.syntaxLayer = new GrSyntaxLayerWorker();
     this._renderPrefs = {
       ...this._renderPrefs,
       use_lit_components: this.flags.isEnabled(
@@ -324,9 +310,6 @@
       'create-comment',
       e => this._handleCreateThread(e)
     );
-    this.addEventListener('normalize-range', event =>
-      this._handleNormalizeRange(event)
-    );
     this.addEventListener('diff-context-expanded', event =>
       this._handleDiffContextExpanded(event)
     );
@@ -385,10 +368,6 @@
     this._getCoverageData();
   }
 
-  diffChanged(diff?: DiffInfo) {
-    this.syntaxLayer.init(diff);
-  }
-
   /**
    * @param shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
@@ -403,11 +382,6 @@
     this._errorMessage = null;
     const whitespaceLevel = this._getIgnoreWhitespace();
 
-    if (shouldReportMetric) {
-      // We listen on render viewport only on DiffPage (on paramsChanged)
-      this._listenToViewportRender();
-    }
-
     try {
       // We are carefully orchestrating operations that have to wait for another
       // and operations that can be run in parallel. Plugins may provide layers,
@@ -432,23 +406,21 @@
       this.filesWeblinks = this._getFilesWeblinks(diff);
       this.diff = diff;
       this.reporting.timeEnd(Timing.DIFF_LOAD, this.timingDetails());
+
       this.reporting.time(Timing.DIFF_CONTENT);
-      const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
+      const syntaxLayerPromise = this.syntaxLayer.process(diff);
+      await waitForEventOnce(this, 'render');
       this.reporting.timeEnd(Timing.DIFF_CONTENT, this.timingDetails());
+
       if (shouldReportMetric) {
         // We report diffViewContentDisplayed only on reload caused
         // by params changed - expected only on Diff Page.
         this.reporting.diffViewContentDisplayed();
       }
-      const needsSyntaxHighlighting = !!event.detail?.contentRendered;
-      if (needsSyntaxHighlighting) {
-        this.reporting.time(Timing.DIFF_SYNTAX);
-        try {
-          await this.syntaxLayer.process();
-        } finally {
-          this.reporting.timeEnd(Timing.DIFF_SYNTAX, this.timingDetails());
-        }
-      }
+
+      this.reporting.time(Timing.DIFF_SYNTAX);
+      await syntaxLayerPromise;
+      this.reporting.timeEnd(Timing.DIFF_SYNTAX, this.timingDetails());
     } catch (e: unknown) {
       if (e instanceof Response) {
         this._handleGetDiffError(e);
@@ -520,9 +492,6 @@
       this.checksChanged([]);
     }
 
-    const experiment = KnownExperimentId.CHECK_RESULTS_IN_DIFFS;
-    if (!this.flagService.isEnabled(experiment)) return;
-
     const path = this.path;
     const patchNum = this.patchRange?.patchNum;
     if (!path || !patchNum || patchNum === EditPatchSetNum) return;
@@ -717,12 +686,6 @@
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
     this.$.diff.cancel();
-    // TODO: Remove calling cancel() when switching to GrSyntaxLayerWorker.
-    // The plan is to only start syntax highlighting when a diff is visible on
-    // screen, and HighlightJS itself does not allow cancelling. So there is
-    // unclear benefit from the ability to cancel, which would be non-trivial
-    // to implement correctly and efficiently.
-    if (this.syntaxLayer instanceof GrSyntaxLayer) this.syntaxLayer.cancel();
   }
 
   getCursorStops() {
@@ -734,7 +697,7 @@
   }
 
   createRangeComment() {
-    return this.$.diff.createRangeComment();
+    this.$.diff.createRangeComment();
   }
 
   toggleLeftDiff() {
@@ -1233,10 +1196,10 @@
       );
       return false;
     }
-    if (this.$.diff.getDiffLength(diff) > SYNTAX_MAX_DIFF_LENGTH) {
+    if (this.$.diff.getDiffLength(diff) > CODE_MAX_LINES) {
       fireAlert(
         this,
-        `Files with more than ${SYNTAX_MAX_DIFF_LENGTH} lines` +
+        `Files with more than ${CODE_MAX_LINES} lines` +
           '  will not be syntax highlighted.'
       );
       return false;
@@ -1244,24 +1207,6 @@
     return true;
   }
 
-  _listenToViewportRender() {
-    const renderUpdateListener: DiffLayerListener = start => {
-      if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
-        this.reporting.diffViewDisplayed();
-        this.syntaxLayer.removeListener(renderUpdateListener);
-      }
-    };
-
-    this.syntaxLayer.addListener(renderUpdateListener);
-  }
-
-  _handleNormalizeRange(event: CustomEvent) {
-    this.reporting.reportInteraction('normalize-range', {
-      side: event.detail.side,
-      lineNum: event.detail.lineNum,
-    });
-  }
-
   _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
     this.reporting.reportInteraction('diff-context-expanded', {
       numLines: e.detail.numLines,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 6d154a6..88aae5c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -21,11 +21,12 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants.js';
 import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
-import {addListenerForTest, mockPromise, stubRestApi} from '../../../test/test-utils.js';
+import {addListenerForTest, mockPromise, stubRestApi, waitUntil} from '../../../test/test-utils.js';
 import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
 import {CoverageType} from '../../../types/types.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image.js';
+import {waitForEventOnce} from '../../../utils/event-util.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -79,8 +80,7 @@
       // Multiple cascading microtasks are scheduled.
       await flush();
       notifySyntaxProcessed();
-      // Multiple cascading microtasks are scheduled.
-      await flush();
+      await waitUntil(() => element.reporting.timeEnd.callCount === 4);
       const calls = element.reporting.timeEnd.getCalls();
       assert.equal(calls.length, 4);
       assert.equal(calls[0].args[0], 'Diff Load Render');
@@ -90,21 +90,6 @@
       assert.isTrue(element.reporting.diffViewContentDisplayed.called);
     });
 
-    test('ends total timer w/ no syntax layer processing', async () => {
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.change = createChange();
-      element.reload();
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      await flush();
-      const calls = element.reporting.timeEnd.getCalls();
-      assert.equal(calls.length, 3);
-      assert.equal(calls[0].args[0], 'Diff Load Render');
-      assert.equal(calls[1].args[0], 'Diff Content Render');
-      assert.equal(calls[2].args[0], 'Diff Total Render');
-    });
-
     test('completes reload promise after syntax layer processing', async () => {
       let notifySyntaxProcessed;
       sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
@@ -124,8 +109,7 @@
       await flush();
       assert.isFalse(reloadComplete);
       notifySyntaxProcessed();
-      // Assert after the notification task is processed.
-      await flush();
+      await waitUntil(() => reloadComplete);
       assert.isTrue(reloadComplete);
     });
   });
@@ -322,64 +306,41 @@
         },
       }));
 
-      const promise = mockPromise();
-      const rendered = () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        // Left image rendered with the parent commit's version of the file.
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-            element.$.diff.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
-
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diff.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
-
-        assert.isNotOk(rightLabelName);
-        assert.isNotOk(leftLabelName);
-
-        let leftLoaded = false;
-        let rightLoaded = false;
-
-        leftImage.addEventListener('load', () => {
-          assert.isOk(leftImage);
-          assert.equal(leftImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile1.body);
-          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-          leftLoaded = true;
-          if (rightLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-
-        rightImage.addEventListener('load', () => {
-          assert.isOk(rightImage);
-          assert.equal(rightImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile2.body);
-          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-          rightLoaded = true;
-          if (leftLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
       element.prefs = createDefaultDiffPrefs();
       element.reload();
-      await promise;
+      await waitForEventOnce(element, 'render');
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assert.instanceOf(
+          element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
+
+      // Left image rendered with the parent commit's version of the file.
+      const leftImage =
+          element.$.diff.$.diffTable.querySelector('td.left img');
+      const leftLabel =
+          element.$.diff.$.diffTable.querySelector('td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage =
+          element.$.diff.$.diffTable.querySelector('td.right img');
+      const rightLabel = element.$.diff.$.diffTable.querySelector(
+          'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(leftImage);
+      assert.equal(leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body);
+      assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+      assert.isNotOk(leftLabelName);
+
+      assert.isOk(rightImage);
+      assert.equal(rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body);
+      assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+      assert.isNotOk(rightLabelName);
     });
 
     test('renders image diffs with a different file name', async () => {
@@ -413,66 +374,44 @@
         },
       }));
 
-      const promise = mockPromise();
-      const rendered = () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        // Left image rendered with the parent commit's version of the file.
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-            element.$.diff.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
-
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diff.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
-
-        assert.isOk(rightLabelName);
-        assert.isOk(leftLabelName);
-        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-        let leftLoaded = false;
-        let rightLoaded = false;
-
-        leftImage.addEventListener('load', () => {
-          assert.isOk(leftImage);
-          assert.equal(leftImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile1.body);
-          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-          leftLoaded = true;
-          if (rightLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-
-        rightImage.addEventListener('load', () => {
-          assert.isOk(rightImage);
-          assert.equal(rightImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile2.body);
-          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-          rightLoaded = true;
-          if (leftLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
       element.prefs = createDefaultDiffPrefs();
       element.reload();
-      await promise;
+      await waitForEventOnce(element, 'render');
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assert.instanceOf(
+          element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
+
+      // Left image rendered with the parent commit's version of the file.
+      const leftImage =
+          element.$.diff.$.diffTable.querySelector('td.left img');
+      const leftLabel =
+          element.$.diff.$.diffTable.querySelector('td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage =
+          element.$.diff.$.diffTable.querySelector('td.right img');
+      const rightLabel = element.$.diff.$.diffTable.querySelector(
+          'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(rightLabelName);
+      assert.isOk(leftLabelName);
+      assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+      assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+      assert.isOk(leftImage);
+      assert.equal(leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body);
+      assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+
+      assert.isOk(rightImage);
+      assert.equal(rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body);
+      assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
     });
 
     test('renders added image', async () => {
@@ -506,7 +445,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
@@ -554,7 +493,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
@@ -604,7 +543,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
         assert.isNotOk(leftImage);
@@ -684,7 +623,7 @@
 
     test('clearBlame', () => {
       element._blame = [];
-      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
+      const setBlameSpy = sinon.spy(element.$.diff.diffBuilder, 'setBlame');
       element.clearBlame();
       assert.isNull(element._blame);
       assert.isTrue(setBlameSpy.calledWithExactly(null));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index 9f38655b..9fc4dd0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -14,90 +14,168 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
+
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-overlay/gr-overlay';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-preferences-dialog_html';
-import {customElement, property} from '@polymer/decorators';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {DiffPreferencesInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css, PropertyValues} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {ValueChangedEvent} from '../../../types/events';
 
-export interface GrDiffPreferencesDialog {
-  $: {
-    diffPreferences: GrDiffPreferences;
-    saveButton: GrButton;
-    cancelButton: GrButton;
-    diffPrefsOverlay: GrOverlay;
-  };
-}
 @customElement('gr-diff-preferences-dialog')
-export class GrDiffPreferencesDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrDiffPreferencesDialog extends LitElement {
+  @query('#diffPreferences') private diffPreferences?: GrDiffPreferences;
+
+  @query('#saveButton') private saveButton?: GrButton;
+
+  @query('#cancelButton') private cancelButton?: GrButton;
+
+  @query('#diffPrefsOverlay') private diffPrefsOverlay?: GrOverlay;
+
+  @state() diffPrefsChanged?: boolean;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .diffHeader,
+        .diffActions {
+          padding: var(--spacing-l) var(--spacing-xl);
+        }
+        .diffHeader,
+        .diffActions {
+          background-color: var(--dialog-background-color);
+        }
+        .diffHeader {
+          border-bottom: 1px solid var(--border-color);
+          font-weight: var(--font-weight-bold);
+        }
+        .diffActions {
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          justify-content: flex-end;
+        }
+        .diffPrefsOverlay gr-button {
+          margin-left: var(--spacing-l);
+        }
+        div.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+        #diffPreferences {
+          display: flex;
+          padding: var(--spacing-s) var(--spacing-xl);
+        }
+      `,
+    ];
   }
 
-  @property({type: Object})
-  diffPrefs?: DiffPreferencesInfo;
+  override render() {
+    return html`
+      <gr-overlay id="diffPrefsOverlay" with-backdrop="">
+        <div role="dialog" aria-labelledby="diffPreferencesTitle">
+          <h3
+            class="heading-3 diffHeader ${this.diffPrefsChanged
+              ? 'edited'
+              : ''}"
+            id="diffPreferencesTitle"
+          >
+            Diff Preferences
+          </h3>
+          <gr-diff-preferences
+            id="diffPreferences"
+            @has-unsaved-changes-changed=${this.handleHasUnsavedChangesChanged}
+          ></gr-diff-preferences>
+          <div class="diffActions">
+            <gr-button
+              id="cancelButton"
+              link=""
+              @click=${this.handleCancelDiff}
+            >
+              Cancel
+            </gr-button>
+            <gr-button
+              id="saveButton"
+              link=""
+              primary=""
+              @click=${() => {
+                this.handleSaveDiffPreferences();
+              }}
+              ?disabled=${!this.diffPrefsChanged}
+            >
+              Save
+            </gr-button>
+          </div>
+        </div>
+      </gr-overlay>
+    `;
+  }
 
-  @property({type: Object})
-  _editableDiffPrefs?: DiffPreferencesInfo;
-
-  @property({type: Boolean, observer: '_onDiffPrefsChanged'})
-  _diffPrefsChanged?: boolean;
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('diffPrefsChanged')) {
+      this.onDiffPrefsChanged();
+    }
+  }
 
   getFocusStops() {
+    assertIsDefined(this.diffPreferences, 'diffPreferences');
+    assertIsDefined(this.saveButton, 'saveButton');
+    assertIsDefined(this.cancelButton, 'cancelbutton');
     return {
-      start: this.$.diffPreferences.$.contextSelect,
-      end: this.$.saveButton.disabled ? this.$.cancelButton : this.$.saveButton,
+      start: this.diffPreferences.contextSelect!,
+      end: this.saveButton.disabled ? this.cancelButton : this.saveButton,
     };
   }
 
   resetFocus() {
-    this.$.diffPreferences.$.contextSelect.focus();
+    assertIsDefined(this.diffPreferences, 'diffPreferences');
+
+    this.diffPreferences.contextSelect!.focus();
   }
 
-  _computeHeaderClass(changed: boolean) {
-    return changed ? 'edited' : '';
-  }
-
-  _handleCancelDiff(e: MouseEvent) {
+  private readonly handleCancelDiff = (e: MouseEvent) => {
     e.stopPropagation();
-    this.$.diffPrefsOverlay.close();
-  }
+    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
+    this.diffPrefsOverlay.close();
+  };
 
-  _onDiffPrefsChanged() {
-    this.$.diffPrefsOverlay.setFocusStops(this.getFocusStops());
+  private onDiffPrefsChanged() {
+    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
+    this.diffPrefsOverlay.setFocusStops(this.getFocusStops());
   }
 
   open() {
-    // JSON.parse(JSON.stringify(...)) makes a deep clone of diffPrefs.
-    // It is known, that diffPrefs is obtained from an RestAPI call and
-    // it is safe to clone the object this way.
-    this._editableDiffPrefs = JSON.parse(
-      JSON.stringify(this.diffPrefs)
-    ) as DiffPreferencesInfo;
-    this.$.diffPrefsOverlay.open().then(() => {
+    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
+    this.diffPrefsOverlay.open().then(() => {
       const focusStops = this.getFocusStops();
-      this.$.diffPrefsOverlay.setFocusStops(focusStops);
+      this.diffPrefsOverlay!.setFocusStops(focusStops);
       this.resetFocus();
     });
   }
 
-  async _handleSaveDiffPreferences() {
-    this.diffPrefs = this._editableDiffPrefs;
-    await this.$.diffPreferences.save();
+  private async handleSaveDiffPreferences() {
+    assertIsDefined(this.diffPreferences, 'diffPreferences');
+    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
+    await this.diffPreferences.save();
     this.dispatchEvent(
       new CustomEvent('reload-diff-preference', {
         composed: true,
         bubbles: false,
       })
     );
-    this.$.diffPrefsOverlay.close();
+    this.diffPrefsOverlay.close();
   }
+
+  private readonly handleHasUnsavedChangesChanged = (
+    e: ValueChangedEvent<boolean>
+  ) => {
+    this.diffPrefsChanged = e.detail.value;
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
deleted file mode 100644
index 85edc12..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .diffHeader,
-    .diffActions {
-      padding: var(--spacing-l) var(--spacing-xl);
-    }
-    .diffHeader,
-    .diffActions {
-      background-color: var(--dialog-background-color);
-    }
-    .diffHeader {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-    }
-    .diffActions {
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      justify-content: flex-end;
-    }
-    .diffPrefsOverlay gr-button {
-      margin-left: var(--spacing-l);
-    }
-    div.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    #diffPreferences {
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-xl);
-    }
-  </style>
-  <gr-overlay id="diffPrefsOverlay" with-backdrop="">
-    <div role="dialog" aria-labelledby="diffPreferencesTitle">
-      <h3
-        class$="heading-3 diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]"
-        id="diffPreferencesTitle"
-      >
-        Diff Preferences
-      </h3>
-      <gr-diff-preferences
-        id="diffPreferences"
-        diff-prefs="{{_editableDiffPrefs}}"
-        has-unsaved-changes="{{_diffPrefsChanged}}"
-      ></gr-diff-preferences>
-      <div class="diffActions">
-        <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
-          Cancel
-        </gr-button>
-        <gr-button
-          id="saveButton"
-          link=""
-          primary=""
-          on-click="_handleSaveDiffPreferences"
-          disabled$="[[!_diffPrefsChanged]]"
-        >
-          Save
-        </gr-button>
-      </div>
-    </div>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index f469799..9f63123 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -19,39 +19,67 @@
 import './gr-diff-preferences-dialog';
 import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
+import {queryAndAssert, stubRestApi, waitUntil} from '../../../test/test-utils';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {ParsedJSON} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-diff-preferences-dialog', () => {
   let element: GrDiffPreferencesDialog;
+  let originalDiffPrefs: DiffPreferencesInfo;
 
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('changes applies only on save', async () => {
-    const originalDiffPrefs = {
+  setup(async () => {
+    originalDiffPrefs = {
       ...createDefaultDiffPrefs(),
       line_wrapping: true,
     };
-    element.diffPrefs = originalDiffPrefs;
-    await flush();
-    element.open();
-    await flush();
-    assert.isTrue(element.$.diffPreferences.$.lineWrappingInput.checked);
 
-    MockInteractions.tap(element.$.diffPreferences.$.lineWrappingInput);
-    await flush();
-    assert.isFalse(element.$.diffPreferences.$.lineWrappingInput.checked);
-    assert.isTrue(element._diffPrefsChanged);
-    assert.isTrue(element.diffPrefs.line_wrapping);
+    stubRestApi('getDiffPreferences').returns(
+      Promise.resolve(originalDiffPrefs)
+    );
+
+    element = await fixture<GrDiffPreferencesDialog>(html`
+      <gr-diff-preferences-dialog></gr-diff-preferences-dialog>
+    `);
+  });
+
+  test('changes applies only on save', async () => {
+    element.open();
+    await element.updateComplete;
+    assert.isUndefined(element.diffPrefsChanged);
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(
+        queryAndAssert(element, '#diffPreferences'),
+        '#lineWrappingInput'
+      ).checked
+    );
+
+    queryAndAssert<HTMLInputElement>(
+      queryAndAssert(element, '#diffPreferences'),
+      '#lineWrappingInput'
+    ).click();
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<HTMLInputElement>(
+        queryAndAssert(element, '#diffPreferences'),
+        '#lineWrappingInput'
+      ).checked
+    );
+    assert.isTrue(element.diffPrefsChanged);
     assert.isTrue(originalDiffPrefs.line_wrapping);
 
-    MockInteractions.tap(element.$.saveButton);
-    await flush();
+    stubRestApi('getResponseObject').returns(
+      Promise.resolve({
+        ...originalDiffPrefs,
+        line_wrapping: false,
+      } as unknown as ParsedJSON)
+    );
+
+    queryAndAssert<GrButton>(element, '#saveButton').click();
+    await element.updateComplete;
     // Original prefs must remains unchanged, dialog must expose a new object
     assert.isTrue(originalDiffPrefs.line_wrapping);
-    assert.isFalse(element.diffPrefs.line_wrapping);
+    await waitUntil(() => element.diffPrefsChanged === false);
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 434788f..e6882fe 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -105,8 +105,17 @@
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
 import {AppElementParams, AppElementDiffViewParam} from '../../gr-app-types';
-import {EventType, OpenFixPreviewEvent} from '../../../types/events';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
+import {
+  EventType,
+  OpenFixPreviewEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
+import {
+  fire,
+  fireAlert,
+  fireEvent,
+  fireTitleChange,
+} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
@@ -177,7 +186,7 @@
   @property({type: Object, observer: '_paramsChanged'})
   params?: AppElementParams;
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   changeViewState: Partial<ChangeViewState> = {};
 
   @property({type: Object})
@@ -767,6 +776,9 @@
       }
 
       this.set('changeViewState.showReplyDialog', true);
+      fire(this, 'view-state-change-view-changed', {
+        value: this.changeViewState as ChangeViewState,
+      });
       this._navToChangeView();
     });
   }
@@ -1170,8 +1182,6 @@
         return this.$.diffHost.reload(true);
       })
       .then(() => {
-        this.reporting.diffViewFullyLoaded();
-        // If diff view displayed has not ended yet, it ends here.
         this.reporting.diffViewDisplayed();
       })
       .then(() => {
@@ -1253,6 +1263,9 @@
     if (!this._fileList || this._fileList.length === 0) return;
 
     this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
+    fire(this, 'view-state-change-view-changed', {
+      value: this.changeViewState as ChangeViewState,
+    });
   }
 
   _getDiffUrl(
@@ -1815,6 +1828,9 @@
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'view-state-change-view-changed': ValueChangedEvent<ChangeViewState>;
+  }
   interface HTMLElementTagNameMap {
     'gr-diff-view': GrDiffView;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index ef38440..677bca3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -421,7 +421,6 @@
   </gr-apply-fix-dialog>
   <gr-diff-preferences-dialog
     id="diffPreferencesDialog"
-    diff-prefs="{{_prefs}}"
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index eeb636f..896a9b2 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -131,11 +131,11 @@
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
+      () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
   }
@@ -180,8 +180,8 @@
       <span class="patchRange" aria-label="patch range starts with">
         <gr-dropdown-list
           id="basePatchDropdown"
-          .value="${convertToString(this.basePatchNum)}"
-          .items="${this.computeBaseDropdownContent()}"
+          .value=${convertToString(this.basePatchNum)}
+          .items=${this.computeBaseDropdownContent()}
           @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
@@ -191,8 +191,8 @@
       <span class="patchRange" aria-label="patch range ends with">
         <gr-dropdown-list
           id="patchNumDropdown"
-          .value="${convertToString(this.patchNum)}"
-          .items="${this.computePatchDropdownContent()}"
+          .value=${convertToString(this.patchNum)}
+          .items=${this.computePatchDropdownContent()}
           @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
@@ -206,7 +206,7 @@
     return html`<span class="filesWeblinks">
       ${fileLinks.map(
         weblink => html`
-          <a target="_blank" rel="noopener" href="${ifDefined(weblink.url)}">
+          <a target="_blank" rel="noopener" href=${ifDefined(weblink.url)}>
             ${weblink.name}
           </a>
         `
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index 342fe3a..2e48771 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -20,7 +20,6 @@
 import '../../shared/revision-info/revision-info';
 import './gr-patch-range-select';
 import {GrPatchRangeSelect} from './gr-patch-range-select';
-import '../../../test/mocks/comment-api';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {stubRestApi} from '../../../test/test-utils';
@@ -80,10 +79,10 @@
 
   test('enabled/disabled options', async () => {
     element.revisions = [
-      createRevision(3) as RevisionInfo,
-      createEditRevision(2) as EditRevisionInfo,
-      createRevision(2) as RevisionInfo,
-      createRevision(1) as RevisionInfo,
+      createRevision(3),
+      createEditRevision(2),
+      createRevision(2),
+      createRevision(1),
     ];
     await element.updateComplete;
 
@@ -259,10 +258,10 @@
     ];
     element.basePatchNum = 1 as BasePatchSetNum;
     element.revisions = [
-      createRevision(3) as RevisionInfo,
-      createEditRevision(2) as EditRevisionInfo,
-      createRevision(2, 'description') as RevisionInfo,
-      createRevision(1) as RevisionInfo,
+      createRevision(3),
+      createEditRevision(2),
+      createRevision(2, 'description'),
+      createRevision(1),
     ];
     await element.updateComplete;
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index e2acdbf..281b7e54 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -70,7 +70,7 @@
             <td>Loading...</td>
           </tr>
         </tbody>
-        <tbody class="${this.loading ? 'loading' : ''}">
+        <tbody class=${this.loading ? 'loading' : ''}>
           ${this.documentationSearches?.map(search =>
             this.renderDocumentationList(search)
           )}
@@ -83,7 +83,7 @@
     return html`
       <tr class="table">
         <td class="name">
-          <a href="${this.computeSearchUrl(search.url)}">${search.title}</a>
+          <a href=${this.computeSearchUrl(search.url)}>${search.title}</a>
         </td>
         <td></td>
         <td></td>
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 5312be2..8b8a615 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -61,7 +61,7 @@
   override render() {
     return html` <textarea
       id="textarea"
-      .value="${this.fileContent}"
+      .value=${this.fileContent}
       @input=${this._handleTextareaInput}
     ></textarea>`;
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 7229b6d..f081037 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -14,47 +14,54 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-edit-controls_html';
 import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
+  GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
-import {IronInputElement} from '@polymer/iron-input';
 import {fireAlert, fireReload} from '../../../utils/event-util';
-import {assertIsDefined} from '../../../utils/common-util';
-
-export interface GrEditControls {
-  $: {
-    newPathIronInput: IronInputElement;
-    overlay: GrOverlay;
-    openDialog: GrDialog;
-    deleteDialog: GrDialog;
-    renameDialog: GrDialog;
-    restoreDialog: GrDialog;
-  };
-}
+import {
+  assertIsDefined,
+  query as queryUtil,
+  queryAll,
+} from '../../../utils/common-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
 
 @customElement('gr-edit-controls')
-export class GrEditControls extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrEditControls extends LitElement {
+  // private but used in test
+  @query('#newPathIronInput') newPathIronInput?: IronInputElement;
+
+  @query('#overlay') protected overlay?: GrOverlay;
+
+  // private but used in test
+  @query('#openDialog') openDialog?: GrDialog;
+
+  // private but used in test
+  @query('#deleteDialog') deleteDialog?: GrDialog;
+
+  // private but used in test
+  @query('#renameDialog') renameDialog?: GrDialog;
+
+  // private but used in test
+  @query('#restoreDialog') restoreDialog?: GrDialog;
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -65,28 +72,229 @@
   @property({type: Array})
   hiddenActions: string[] = [GrEditConstants.Actions.RESTORE.id];
 
-  @property({type: Array})
-  _actions: GrEditAction[] = Object.values(GrEditConstants.Actions);
+  // private but used in test
+  @state() actions: GrEditAction[] = Object.values(GrEditConstants.Actions);
 
-  @property({type: String})
-  _path = '';
+  // private but used in test
+  @state() path = '';
 
-  @property({type: String})
-  _newPath = '';
+  // private but used in test
+  @state() newPath = '';
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  private readonly query: AutocompleteQuery = (input: string) =>
+    this.queryFiles(input);
 
   private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = (input: string) => this._queryFiles(input);
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          align-items: center;
+          display: flex;
+          justify-content: flex-end;
+        }
+        .invisible {
+          display: none;
+        }
+        gr-button {
+          margin-left: var(--spacing-l);
+          text-decoration: none;
+        }
+        gr-dialog {
+          width: 50em;
+        }
+        gr-dialog .main {
+          width: 100%;
+        }
+        gr-dialog .main > iron-input {
+          width: 100%;
+        }
+        input {
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          margin: var(--spacing-m) 0;
+          padding: var(--spacing-s);
+          width: 100%;
+          box-sizing: content-box;
+        }
+        #fileUploadBrowse {
+          margin-left: 0;
+        }
+        #dragDropArea {
+          border: 2px dashed var(--border-color);
+          border-radius: var(--border-radius);
+          margin-top: var(--spacing-l);
+          padding: var(--spacing-xxl) var(--spacing-xxl);
+          text-align: center;
+        }
+        #dragDropArea > p {
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-s);
+        }
+        @media screen and (max-width: 50em) {
+          gr-dialog {
+            width: 100vw;
+          }
+        }
+      `,
+    ];
   }
 
-  _handleTap(e: Event) {
+  override render() {
+    return html`
+      ${this.actions.map(action => this.renderAction(action))}
+      <gr-overlay id="overlay" with-backdrop="">
+        ${this.renderOpenDialog()} ${this.renderDeleteDialog()}
+        ${this.renderRenameDialog()} ${this.renderRestoreDialog()}
+      </gr-overlay>
+    `;
+  }
+
+  private renderAction(action: GrEditAction) {
+    return html`
+      <gr-button
+        id=${action.id}
+        class=${this.computeIsInvisible(action.id)}
+        link=""
+        @click=${this.handleTap}
+        >${action.label}</gr-button
+      >
+    `;
+  }
+
+  private renderOpenDialog() {
+    return html`
+      <gr-dialog
+        id="openDialog"
+        class="invisible dialog"
+        ?disabled=${!this.isValidPath(this.path)}
+        confirm-label="Confirm"
+        confirm-on-enter=""
+        @confirm=${this.handleOpenConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">
+          Add a new file or open an existing file
+        </div>
+        <div class="main" slot="main">
+          <gr-autocomplete
+            placeholder="Enter an existing or new full file path."
+            .query=${this.query}
+            .text=${this.path}
+            @text-changed=${this.handleTextChanged}
+          ></gr-autocomplete>
+          <div
+            id="dragDropArea"
+            contenteditable="true"
+            @drop=${this.handleDragAndDropUpload}
+            @keypress=${this.handleKeyPress}
+          >
+            <p>Drag and drop a file here</p>
+            <p>or</p>
+            <p>
+              <iron-input>
+                <input
+                  id="fileUploadInput"
+                  type="file"
+                  @change=${this.handleFileUploadChanged}
+                  multiple
+                  hidden
+                />
+              </iron-input>
+              <label for="fileUploadInput">
+                <gr-button id="fileUploadBrowse">Browse</gr-button>
+              </label>
+            </p>
+          </div>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderDeleteDialog() {
+    return html`
+      <gr-dialog
+        id="deleteDialog"
+        class="invisible dialog"
+        ?disabled=${!this.isValidPath(this.path)}
+        confirm-label="Delete"
+        confirm-on-enter=""
+        @confirm=${this.handleDeleteConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">Delete a file from the repo</div>
+        <div class="main" slot="main">
+          <gr-autocomplete
+            placeholder="Enter an existing full file path."
+            .query=${this.query}
+            .text=${this.path}
+            @text-changed=${this.handleTextChanged}
+          ></gr-autocomplete>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderRenameDialog() {
+    return html`
+      <gr-dialog
+        id="renameDialog"
+        class="invisible dialog"
+        ?disabled=${!this.isValidPath(this.path) ||
+        !this.isValidPath(this.newPath)}
+        confirm-label="Rename"
+        confirm-on-enter=""
+        @confirm=${this.handleRenameConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">Rename a file in the repo</div>
+        <div class="main" slot="main">
+          <gr-autocomplete
+            placeholder="Enter an existing full file path."
+            .query=${this.query}
+            .text=${this.path}
+            @text-changed=${this.handleTextChanged}
+          ></gr-autocomplete>
+          <iron-input
+            id="newPathIronInput"
+            .bindValue=${this.newPath}
+            @bind-value-changed=${this.handleBindValueChangedNewPath}
+          >
+            <input id="newPathInput" placeholder="Enter the new path." />
+          </iron-input>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderRestoreDialog() {
+    return html`
+      <gr-dialog
+        id="restoreDialog"
+        class="invisible dialog"
+        confirm-label="Restore"
+        confirm-on-enter=""
+        @confirm=${this.handleRestoreConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">Restore this file?</div>
+        <div class="main" slot="main">
+          <iron-input
+            .bindValue=${this.path}
+            @bind-value-changed=${this.handleBindValueChangedPath}
+          >
+            <input ?disabled=${''} />
+          </iron-input>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private readonly handleTap = (e: Event) => {
     e.preventDefault();
-    const target = (dom(e) as EventApi).localTarget as Element;
+    const target = e.target as Element;
     const action = target.id;
     switch (action) {
       case GrEditConstants.Actions.OPEN.id:
@@ -102,91 +310,99 @@
         this.openRestoreDialog();
         return;
     }
-  }
+  };
 
   openOpenDialog(path?: string) {
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.openDialog);
+    assertIsDefined(this.openDialog, 'openDialog');
+    return this.showDialog(this.openDialog);
   }
 
   openDeleteDialog(path?: string) {
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.deleteDialog);
+    assertIsDefined(this.deleteDialog, 'deleteDialog');
+    return this.showDialog(this.deleteDialog);
   }
 
   openRenameDialog(path?: string) {
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.renameDialog);
+    assertIsDefined(this.renameDialog, 'renameDialog');
+    return this.showDialog(this.renameDialog);
   }
 
   openRestoreDialog(path?: string) {
+    assertIsDefined(this.restoreDialog, 'restoreDialog');
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.restoreDialog);
+    return this.showDialog(this.restoreDialog);
   }
 
   /**
    * Given a path string, checks that it is a valid file path.
+   *
+   * private but used in test
    */
-  _isValidPath(path: string) {
+  isValidPath(path: string) {
     // Double negation needed for strict boolean return type.
     return !!path.length && !path.endsWith('/');
   }
 
-  _computeRenameDisabled(path: string, newPath: string) {
-    return this._isValidPath(path) && this._isValidPath(newPath);
-  }
-
   /**
    * Given a dom event, gets the dialog that lies along this event path.
+   *
+   * private but used in test
    */
-  _getDialogFromEvent(e: Event): GrDialog | undefined {
-    return (dom(e) as EventApi).path.find(element => {
+  getDialogFromEvent(e: Event): GrDialog | undefined {
+    return e.composedPath().find(element => {
       if (!(element instanceof Element)) return false;
       if (!element.classList) return false;
       return element.classList.contains('dialog');
     }) as GrDialog | undefined;
   }
 
-  _showDialog(dialog: GrDialog) {
+  // private but used in test
+  showDialog(dialog: GrDialog) {
+    assertIsDefined(this.overlay, 'overlay');
+
     // Some dialogs may not fire their on-close event when closed in certain
     // ways (e.g. by clicking outside the dialog body). This call prevents
     // multiple dialogs from being shown in the same overlay.
-    this._hideAllDialogs();
+    this.hideAllDialogs();
 
-    return this.$.overlay.open().then(() => {
+    return this.overlay.open().then(() => {
       dialog.classList.toggle('invisible', false);
-      const autocomplete = dialog.querySelector('gr-autocomplete');
+      const autocomplete = queryUtil<GrAutocomplete>(dialog, 'gr-autocomplete');
       if (autocomplete) {
         autocomplete.focus();
       }
       setTimeout(() => {
-        this.$.overlay.center();
+        assertIsDefined(this.overlay, 'overlay');
+        this.overlay.center();
       }, 1);
     });
   }
 
-  _hideAllDialogs() {
-    const dialogs = this.root!.querySelectorAll(
-      '.dialog'
-    ) as NodeListOf<GrDialog>;
+  // private but used in test
+  hideAllDialogs() {
+    const dialogs = queryAll<GrDialog>(this, '.dialog');
     for (const dialog of dialogs) {
       // We set the second param to false, because this function
-      // is called by _showDialog which when you open either restore,
+      // is called by showDialog which when you open either restore,
       // delete or rename dialogs, it reseted the automatically
       // set input.
-      this._closeDialog(dialog, false);
+      this.closeDialog(dialog, false);
     }
   }
 
-  _closeDialog(dialog?: GrDialog, clearInputs = true) {
+  // private but used in test
+  closeDialog(dialog?: GrDialog, clearInputs = true) {
     if (!dialog) return;
 
     if (clearInputs) {
@@ -203,32 +419,35 @@
     }
 
     dialog.classList.toggle('invisible', true);
-    return this.$.overlay.close();
+
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.close();
   }
 
-  _handleDialogCancel(e: Event) {
-    this._closeDialog(this._getDialogFromEvent(e));
-  }
+  private readonly handleDialogCancel = (e: Event) => {
+    this.closeDialog(this.getDialogFromEvent(e));
+  };
 
-  _handleOpenConfirm(e: Event) {
-    if (!this.change || !this._path) {
+  private readonly handleOpenConfirm = (e: Event) => {
+    if (!this.change || !this.path) {
       fireAlert(this, 'You must enter a path.');
-      this._closeDialog(this.$.openDialog);
+      this.closeDialog(this.openDialog);
       return;
     }
     const url = GerritNav.getEditUrlForDiff(
       this.change,
-      this._path,
+      this.path,
       this.patchNum
     );
     GerritNav.navigateToRelativeUrl(url);
-    this._closeDialog(this._getDialogFromEvent(e));
-  }
+    this.closeDialog(this.getDialogFromEvent(e));
+  };
 
-  _handleUploadConfirm(path: string, fileData: string) {
+  // private but used in test
+  handleUploadConfirm(path: string, fileData: string) {
     if (!this.change || !path || !fileData) {
       fireAlert(this, 'You must enter a path and data.');
-      this._closeDialog(this.$.openDialog);
+      this.closeDialog(this.openDialog);
       return Promise.resolve();
     }
     return this.restApiService
@@ -237,68 +456,68 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(this.$.openDialog);
+        this.closeDialog(this.openDialog);
         fireReload(this, true);
       });
   }
 
-  _handleDeleteConfirm(e: Event) {
+  private readonly handleDeleteConfirm = (e: Event) => {
     // Get the dialog before the api call as the event will change during bubbling
     // which will make Polymer.dom(e).path an empty array in polymer 2
-    const dialog = this._getDialogFromEvent(e);
-    if (!this.change || !this._path) {
+    const dialog = this.getDialogFromEvent(e);
+    if (!this.change || !this.path) {
       fireAlert(this, 'You must enter a path.');
-      this._closeDialog(dialog);
+      this.closeDialog(dialog);
       return;
     }
     this.restApiService
-      .deleteFileInChangeEdit(this.change._number, this._path)
+      .deleteFileInChangeEdit(this.change._number, this.path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog);
+        this.closeDialog(dialog);
         fireReload(this);
       });
-  }
+  };
 
-  _handleRestoreConfirm(e: Event) {
-    const dialog = this._getDialogFromEvent(e);
-    if (!this.change || !this._path) {
+  private readonly handleRestoreConfirm = (e: Event) => {
+    const dialog = this.getDialogFromEvent(e);
+    if (!this.change || !this.path) {
       fireAlert(this, 'You must enter a path.');
-      this._closeDialog(dialog);
+      this.closeDialog(dialog);
       return;
     }
     this.restApiService
-      .restoreFileInChangeEdit(this.change._number, this._path)
+      .restoreFileInChangeEdit(this.change._number, this.path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog);
+        this.closeDialog(dialog);
         fireReload(this);
       });
-  }
+  };
 
-  _handleRenameConfirm(e: Event) {
-    const dialog = this._getDialogFromEvent(e);
-    if (!this.change || !this._path || !this._newPath) {
+  private readonly handleRenameConfirm = (e: Event) => {
+    const dialog = this.getDialogFromEvent(e);
+    if (!this.change || !this.path || !this.newPath) {
       fireAlert(this, 'You must enter a old path and a new path.');
-      this._closeDialog(dialog);
+      this.closeDialog(dialog);
       return;
     }
-    return this.restApiService
-      .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
+    this.restApiService
+      .renameFileInChangeEdit(this.change._number, this.path, this.newPath)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog);
+        this.closeDialog(dialog);
         fireReload(this, true);
       });
-  }
+  };
 
-  _queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
+  private queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
     assertIsDefined(this.change, 'this.change');
     assertIsDefined(this.patchNum, 'this.patchNum');
     return this.restApiService
@@ -312,31 +531,31 @@
       });
   }
 
-  _computeIsInvisible(id: string, hiddenActions: string[]) {
-    return hiddenActions.includes(id) ? 'invisible' : '';
+  private computeIsInvisible(id: string) {
+    return this.hiddenActions.includes(id) ? 'invisible' : '';
   }
 
-  _handleDragAndDropUpload(event: DragEvent) {
-    event.preventDefault();
-    event.stopPropagation();
+  private readonly handleDragAndDropUpload = (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
 
-    if (!event.dataTransfer) return;
-    this._fileUpload(event.dataTransfer.files);
-  }
+    if (!e.dataTransfer) return;
+    this.fileUpload(e.dataTransfer.files);
+  };
 
-  _handleFileUploadChanged(event: InputEvent) {
-    if (!event.target) return;
-    if (!(event.target instanceof HTMLInputElement)) return;
-    const input = event.target as HTMLInputElement;
+  private readonly handleFileUploadChanged = (e: InputEvent) => {
+    if (!e.target) return;
+    if (!(e.target instanceof HTMLInputElement)) return;
+    const input = e.target;
     if (!input.files) return;
-    this._fileUpload(input.files);
-  }
+    this.fileUpload(input.files);
+  };
 
-  _fileUpload(files: FileList) {
+  private fileUpload(files: FileList) {
     for (const file of files) {
       if (!file) continue;
 
-      let path = this._path;
+      let path = this.path;
       if (!path) {
         path = file.name;
       }
@@ -348,16 +567,30 @@
         if (!fileLoadEvent) return;
         const fileData = fileLoadEvent.target!.result;
         if (typeof fileData !== 'string') return;
-        this._handleUploadConfirm(path, fileData);
+        this.handleUploadConfirm(path, fileData);
       };
       fr.readAsDataURL(file);
     }
   }
 
-  _handleKeyPress(event: KeyboardEvent) {
-    event.preventDefault();
-    event.stopImmediatePropagation();
-  }
+  private readonly handleKeyPress = (e: KeyboardEvent) => {
+    e.preventDefault();
+    e.stopImmediatePropagation();
+  };
+
+  private readonly handleTextChanged = (e: BindValueChangeEvent) => {
+    this.path = e.detail.value;
+  };
+
+  private readonly handleBindValueChangedNewPath = (
+    e: BindValueChangeEvent
+  ) => {
+    this.newPath = e.detail.value;
+  };
+
+  private readonly handleBindValueChangedPath = (e: BindValueChangeEvent) => {
+    this.path = e.detail.value;
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
deleted file mode 100644
index 2e25659..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: flex;
-      justify-content: flex-end;
-    }
-    .invisible {
-      display: none;
-    }
-    gr-button {
-      margin-left: var(--spacing-l);
-      text-decoration: none;
-    }
-    gr-dialog {
-      width: 50em;
-    }
-    gr-dialog .main {
-      width: 100%;
-    }
-    gr-dialog .main > iron-input {
-      width: 100%;
-    }
-    input {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin: var(--spacing-m) 0;
-      padding: var(--spacing-s);
-      width: 100%;
-      box-sizing: content-box;
-    }
-    #fileUploadBrowse {
-      margin-left: 0;
-    }
-    #dragDropArea {
-      border: 2px dashed var(--border-color);
-      border-radius: var(--border-radius);
-      margin-top: var(--spacing-l);
-      padding: var(--spacing-xxl) var(--spacing-xxl);
-      text-align: center;
-    }
-    #dragDropArea > p {
-      font-weight: var(--font-weight-bold);
-      padding: var(--spacing-s);
-    }
-    @media screen and (max-width: 50em) {
-      gr-dialog {
-        width: 100vw;
-      }
-    }
-  </style>
-  <template is="dom-repeat" items="[[_actions]]" as="action">
-    <gr-button
-      id$="[[action.id]]"
-      class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
-      link=""
-      on-click="_handleTap"
-      >[[action.label]]</gr-button
-    >
-  </template>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-dialog
-      id="openDialog"
-      class="invisible dialog"
-      disabled$="[[!_isValidPath(_path)]]"
-      confirm-label="Confirm"
-      confirm-on-enter=""
-      on-confirm="_handleOpenConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">
-        Add a new file or open an existing file
-      </div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing or new full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-        <div
-          id="dragDropArea"
-          contenteditable="true"
-          on-drop="_handleDragAndDropUpload"
-          on-keypress="_handleKeyPress"
-        >
-          <p>Drag and drop a file here</p>
-          <p>or</p>
-          <p>
-            <iron-input>
-              <input
-                id="fileUploadInput"
-                type="file"
-                on-change="_handleFileUploadChanged"
-                multiple
-                hidden
-              />
-            </iron-input>
-            <label for="fileUploadInput">
-              <gr-button id="fileUploadBrowse">Browse</gr-button>
-            </label>
-          </p>
-        </div>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="deleteDialog"
-      class="invisible dialog"
-      disabled$="[[!_isValidPath(_path)]]"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-confirm="_handleDeleteConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Delete a file from the repo</div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="renameDialog"
-      class="invisible dialog"
-      disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
-      confirm-label="Rename"
-      confirm-on-enter=""
-      on-confirm="_handleRenameConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Rename a file in the repo</div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-        <iron-input
-          id="newPathIronInput"
-          bind-value="{{_newPath}}"
-          placeholder="Enter the new path."
-        >
-          <input id="newPathInput" placeholder="Enter the new path." />
-        </iron-input>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="restoreDialog"
-      class="invisible dialog"
-      confirm-label="Restore"
-      confirm-on-enter=""
-      on-confirm="_handleRestoreConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Restore this file?</div>
-      <div class="main" slot="main">
-        <iron-input disabled="" bind-value="{{_path}}">
-          <input disabled="" />
-        </iron-input>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index 58fa85e..8376d34 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -19,15 +19,16 @@
 import './gr-edit-controls';
 import {GrEditControls} from './gr-edit-controls';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAll, stubRestApi, waitUntil} from '../../../test/test-utils';
 import {createChange, createRevision} from '../../../test/test-data-generators';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {CommitId, NumericChangeId, PatchSetNum} from '../../../types/common';
 import {RepoName} from '../../../api/rest-api';
 import {queryAndAssert} from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-edit-controls');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -37,23 +38,25 @@
   let hideDialogStub: sinon.SinonStub;
   let queryStub: sinon.SinonStub;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrEditControls>(html`
+      <gr-edit-controls></gr-edit-controls>
+    `);
     element.change = createChange();
     element.patchNum = 1 as PatchSetNum;
-    showDialogSpy = sinon.spy(element, '_showDialog');
-    closeDialogSpy = sinon.spy(element, '_closeDialog');
-    hideDialogStub = sinon.stub(element, '_hideAllDialogs');
+    showDialogSpy = sinon.spy(element, 'showDialog');
+    closeDialogSpy = sinon.spy(element, 'closeDialog');
+    hideDialogStub = sinon.stub(element, 'hideAllDialogs');
     queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
-    flush();
+    await element.updateComplete;
   });
 
   test('all actions exist', () => {
     // We take 1 away from the total found, due to an extra button being
     // added for the file uploads (browse).
     assert.equal(
-      element.root!.querySelectorAll('gr-button').length - 1,
-      element._actions.length
+      queryAll<GrButton>(element, 'gr-button').length - 1,
+      element.actions.length
     );
   });
 
@@ -65,16 +68,18 @@
     setup(() => {
       editDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
       navStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      openAutoComplete =
-        element.$.openDialog!.querySelector('gr-autocomplete')!;
+      openAutoComplete = queryAndAssert<GrAutocomplete>(
+        element.openDialog,
+        'gr-autocomplete'
+      );
     });
 
-    test('_isValidPath', () => {
-      assert.isFalse(element._isValidPath(''));
-      assert.isFalse(element._isValidPath('test/'));
-      assert.isFalse(element._isValidPath('/'));
-      assert.isTrue(element._isValidPath('test/path.cpp'));
-      assert.isTrue(element._isValidPath('test.js'));
+    test('isValidPath', () => {
+      assert.isFalse(element.isValidPath(''));
+      assert.isFalse(element.isValidPath('test/'));
+      assert.isFalse(element.isValidPath('/'));
+      assert.isTrue(element.isValidPath('test/path.cpp'));
+      assert.isTrue(element.isValidPath('test.js'));
     });
 
     test('open', async () => {
@@ -83,20 +88,22 @@
       element.patchNum = 1 as PatchSetNum;
       await showDialogSpy.lastCall.returnValue;
       assert.isTrue(hideDialogStub.called);
-      assert.isTrue(element.$.openDialog.disabled);
+      assert.isTrue(element.openDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // Setup focused manually - in headless mode Chrome sometimes don't
       // setup focus. flush and/or flushAsynchronousOperations don't help
-      openAutoComplete._focused = true;
+      openAutoComplete.focused = true;
       openAutoComplete.noDebounce = true;
       openAutoComplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isFalse(element.$.openDialog.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.$.openDialog, 'gr-button[primary]')
-      );
-      assert.isTrue(editDiffStub.called);
+      await waitUntil(() => !element.openDialog!.disabled);
+      queryAndAssert<GrButton>(
+        element.openDialog,
+        'gr-button[primary]'
+      ).click();
+      await waitUntil(() => editDiffStub.called);
+
       assert.isTrue(navStub.called);
       assert.deepEqual(editDiffStub.lastCall.args, [
         element.change,
@@ -106,18 +113,21 @@
       assert.isTrue(closeDialogSpy.called);
     });
 
-    test('cancel', () => {
+    test('cancel', async () => {
       MockInteractions.tap(queryAndAssert(element, '#open'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.openDialog.disabled);
+      return showDialogSpy.lastCall.returnValue.then(async () => {
+        assert.isTrue(element.openDialog!.disabled);
         openAutoComplete.noDebounce = true;
         openAutoComplete.text = 'src/test.cpp';
-        assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(queryAndAssert(element.$.openDialog, 'gr-button'));
+        await element.updateComplete;
+        await waitUntil(() => !element.openDialog!.disabled);
+        MockInteractions.tap(
+          queryAndAssert<GrButton>(element.openDialog, 'gr-button')
+        );
         assert.isFalse(editDiffStub.called);
         assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
+        await waitUntil(() => closeDialogSpy.called);
+        assert.equal(element.path, '');
       });
     });
   });
@@ -130,32 +140,35 @@
     setup(() => {
       eventStub = sinon.stub(element, 'dispatchEvent');
       deleteStub = stubRestApi('deleteFileInChangeEdit');
-      deleteAutocomplete =
-        element.$.deleteDialog!.querySelector('gr-autocomplete')!;
+      const deleteDialog = element.deleteDialog;
+      deleteAutocomplete = queryAndAssert<GrAutocomplete>(
+        deleteDialog,
+        'gr-autocomplete'
+      );
     });
 
     test('delete', async () => {
       deleteStub.returns(Promise.resolve({ok: true}));
       MockInteractions.tap(queryAndAssert(element, '#delete'));
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.deleteDialog.disabled);
+      assert.isTrue(element.deleteDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // Setup focused manually - in headless mode Chrome sometimes don't
       // setup focus. flush and/or flushAsynchronousOperations don't help
-      deleteAutocomplete._focused = true;
+      deleteAutocomplete.focused = true;
       deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isFalse(element.$.deleteDialog.disabled);
+      await waitUntil(() => !element.deleteDialog!.disabled);
       MockInteractions.tap(
-        queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+        queryAndAssert(element.deleteDialog, 'gr-button[primary]')
       );
-      await flush();
+      await element.updateComplete;
 
       assert.isTrue(deleteStub.called);
       await deleteStub.lastCall.returnValue;
-      assert.equal(element._path, '');
+      assert.equal(element.path, '');
       assert.equal(eventStub.firstCall.args[0].type, 'reload');
       assert.isTrue(closeDialogSpy.called);
     });
@@ -164,20 +177,20 @@
       deleteStub.returns(Promise.resolve({ok: false}));
       MockInteractions.tap(queryAndAssert(element, '#delete'));
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.deleteDialog.disabled);
+      assert.isTrue(element.deleteDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // Setup focused manually - in headless mode Chrome sometimes don't
       // setup focus. flush and/or flushAsynchronousOperations don't help
-      deleteAutocomplete._focused = true;
+      deleteAutocomplete.focused = true;
       deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isFalse(element.$.deleteDialog.disabled);
+      await waitUntil(() => !element.deleteDialog!.disabled);
       MockInteractions.tap(
-        queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+        queryAndAssert(element.deleteDialog, 'gr-button[primary]')
       );
-      await flush();
+      await element.updateComplete;
 
       assert.isTrue(deleteStub.called);
 
@@ -188,17 +201,18 @@
 
     test('cancel', () => {
       MockInteractions.tap(queryAndAssert(element, '#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        element.$.deleteDialog!.querySelector('gr-autocomplete')!.text =
-          'src/test.cpp';
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(
-          queryAndAssert(element.$.deleteDialog, 'gr-button')
-        );
+      return showDialogSpy.lastCall.returnValue.then(async () => {
+        assert.isTrue(element.deleteDialog!.disabled);
+        queryAndAssert<GrAutocomplete>(
+          element.deleteDialog,
+          'gr-autocomplete'
+        ).text = 'src/test.cpp';
+        await element.updateComplete;
+        await waitUntil(() => !element.deleteDialog!.disabled);
+        MockInteractions.tap(queryAndAssert(element.deleteDialog, 'gr-button'));
         assert.isFalse(eventStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
+        await waitUntil(() => element.path === '');
       });
     });
   });
@@ -211,37 +225,40 @@
     setup(() => {
       eventStub = sinon.stub(element, 'dispatchEvent');
       renameStub = stubRestApi('renameFileInChangeEdit');
-      renameAutocomplete =
-        element.$.renameDialog!.querySelector('gr-autocomplete')!;
+      const renameDialog = element.renameDialog;
+      renameAutocomplete = queryAndAssert<GrAutocomplete>(
+        renameDialog,
+        'gr-autocomplete'
+      );
     });
 
     test('rename', async () => {
       renameStub.returns(Promise.resolve({ok: true}));
       MockInteractions.tap(queryAndAssert(element, '#rename'));
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // Setup focused manually - in headless mode Chrome sometimes don't
       // setup focus. flush and/or flushAsynchronousOperations don't help
-      renameAutocomplete._focused = true;
+      renameAutocomplete.focused = true;
       renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
 
-      element.$.newPathIronInput.bindValue = 'src/test.newPath';
-      await flush();
+      element.newPathIronInput!.bindValue = 'src/test.newPath';
+      await element.updateComplete;
 
-      assert.isFalse(element.$.renameDialog.disabled);
+      assert.isFalse(element.renameDialog!.disabled);
       MockInteractions.tap(
-        queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+        queryAndAssert(element.renameDialog, 'gr-button[primary]')
       );
-      await flush();
+      await element.updateComplete;
       assert.isTrue(renameStub.called);
 
       await renameStub.lastCall.returnValue;
-      assert.equal(element._path, '');
+      assert.equal(element.path, '');
       assert.equal(eventStub.firstCall.args[0].type, 'reload');
       assert.isTrue(closeDialogSpy.called);
     });
@@ -250,25 +267,25 @@
       renameStub.returns(Promise.resolve({ok: false}));
       MockInteractions.tap(queryAndAssert(element, '#rename'));
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // Setup focused manually - in headless mode Chrome sometimes don't
       // setup focus. flush and/or flushAsynchronousOperations don't help
-      renameAutocomplete._focused = true;
+      renameAutocomplete.focused = true;
       renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
 
-      element.$.newPathIronInput.bindValue = 'src/test.newPath';
-      await flush();
+      element.newPathIronInput!.bindValue = 'src/test.newPath';
+      await element.updateComplete;
 
-      assert.isFalse(element.$.renameDialog.disabled);
+      assert.isFalse(element.renameDialog!.disabled);
       MockInteractions.tap(
-        queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+        queryAndAssert(element.renameDialog, 'gr-button[primary]')
       );
-      await flush();
+      await element.updateComplete;
 
       assert.isTrue(renameStub.called);
 
@@ -279,19 +296,19 @@
 
     test('cancel', () => {
       MockInteractions.tap(queryAndAssert(element, '#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        element.$.renameDialog!.querySelector('gr-autocomplete')!.text =
-          'src/test.cpp';
-        element.$.newPathIronInput.bindValue = 'src/test.newPath';
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(
-          queryAndAssert(element.$.renameDialog, 'gr-button')
-        );
+      return showDialogSpy.lastCall.returnValue.then(async () => {
+        assert.isTrue(element.renameDialog!.disabled);
+        queryAndAssert<GrAutocomplete>(
+          element.renameDialog,
+          'gr-autocomplete'
+        ).text = 'src/test.cpp';
+        element.newPathIronInput!.bindValue = 'src/test.newPath';
+        await element.updateComplete;
+        assert.isFalse(element.renameDialog!.disabled);
+        MockInteractions.tap(queryAndAssert(element.renameDialog, 'gr-button'));
         assert.isFalse(eventStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
-        assert.equal(element._newPath, '');
+        await waitUntil(() => element.path === '');
       });
     });
   });
@@ -307,24 +324,24 @@
 
     test('restore hidden by default', () => {
       assert.isTrue(
-        queryAndAssert(element, '#restore').classList!.contains('invisible')!
+        queryAndAssert(element, '#restore').classList.contains('invisible')!
       );
     });
 
     test('restore', () => {
       restoreStub.returns(Promise.resolve({ok: true}));
-      element._path = 'src/test.cpp';
+      element.path = 'src/test.cpp';
       MockInteractions.tap(queryAndAssert(element, '#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
+      return showDialogSpy.lastCall.returnValue.then(async () => {
         MockInteractions.tap(
-          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+          queryAndAssert(element.restoreDialog, 'gr-button[primary]')
         );
-        flush();
+        await element.updateComplete;
 
         assert.isTrue(restoreStub.called);
         assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
         return restoreStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
+          assert.equal(element.path, '');
           assert.equal(eventStub.firstCall.args[0].type, 'reload');
           assert.isTrue(closeDialogSpy.called);
         });
@@ -333,13 +350,13 @@
 
     test('restore fails', () => {
       restoreStub.returns(Promise.resolve({ok: false}));
-      element._path = 'src/test.cpp';
+      element.path = 'src/test.cpp';
       MockInteractions.tap(queryAndAssert(element, '#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
+      return showDialogSpy.lastCall.returnValue.then(async () => {
         MockInteractions.tap(
-          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+          queryAndAssert(element.restoreDialog, 'gr-button[primary]')
         );
-        flush();
+        await element.updateComplete;
 
         assert.isTrue(restoreStub.called);
         assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
@@ -351,15 +368,15 @@
     });
 
     test('cancel', () => {
-      element._path = 'src/test.cpp';
+      element.path = 'src/test.cpp';
       MockInteractions.tap(queryAndAssert(element, '#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         MockInteractions.tap(
-          queryAndAssert(element.$.restoreDialog, 'gr-button')
+          queryAndAssert(element.restoreDialog, 'gr-button')
         );
         assert.isFalse(eventStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
+        assert.equal(element.path, '');
       });
     });
   });
@@ -373,7 +390,7 @@
       fileStub = stubRestApi('saveFileUploadChangeEdit');
     });
 
-    test('_handleUploadConfirm', () => {
+    test('handleUploadConfirm', () => {
       fileStub.returns(Promise.resolve({ok: true}));
 
       element.change = {
@@ -393,7 +410,7 @@
         current_revision: 'efgh' as CommitId,
       };
 
-      element._handleUploadConfirm('test.php', 'base64').then(() => {
+      element.handleUploadConfirm('test.php', 'base64').then(() => {
         assert.isTrue(navStub.calledWithExactly(1 as NumericChangeId));
       });
     });
@@ -401,33 +418,34 @@
 
   test('openOpenDialog', async () => {
     await element.openOpenDialog('test/path.cpp');
-    assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+    assert.isFalse(element.openDialog!.hasAttribute('hidden'));
     assert.equal(
-      element.$.openDialog!.querySelector('gr-autocomplete')!.text,
+      queryAndAssert<GrAutocomplete>(element.openDialog, 'gr-autocomplete')
+        .text,
       'test/path.cpp'
     );
   });
 
-  test('_getDialogFromEvent', () => {
-    const spy = sinon.spy(element, '_getDialogFromEvent');
-    element.addEventListener('tap', element._getDialogFromEvent);
+  test('getDialogFromEvent', async () => {
+    const spy = sinon.spy(element, 'getDialogFromEvent');
+    element.addEventListener('tap', element.getDialogFromEvent);
 
-    MockInteractions.tap(element.$.openDialog);
-    flush();
-    assert.equal(spy!.lastCall!.returnValue!.id, 'openDialog');
+    MockInteractions.tap(element.openDialog!);
+    await element.updateComplete;
+    assert.equal(spy.lastCall.returnValue!.id, 'openDialog');
 
-    MockInteractions.tap(element.$.deleteDialog);
-    flush();
-    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
+    MockInteractions.tap(element.deleteDialog!);
+    await element.updateComplete;
+    assert.equal(spy.lastCall.returnValue!.id, 'deleteDialog');
 
     MockInteractions.tap(
-      element.$.deleteDialog!.querySelector('gr-autocomplete')!
+      queryAndAssert<GrAutocomplete>(element.deleteDialog, 'gr-autocomplete')
     );
-    flush();
-    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
+    await element.updateComplete;
+    assert.equal(spy.lastCall.returnValue!.id, 'deleteDialog');
 
     MockInteractions.tap(element);
-    flush();
-    assert.notOk(spy!.lastCall!.returnValue);
+    await element.updateComplete;
+    assert.notOk(spy.lastCall.returnValue);
   });
 });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 1b854b4..a770d5b 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -82,7 +82,7 @@
         .items=${fileActions}
         down-arrow=""
         vertical-offset="20"
-        @tap-item="${this._handleActionTap}"
+        @tap-item=${this._handleActionTap}
         link=""
         >Actions</gr-dropdown
       >`;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 08411c0..48d50c7 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -14,20 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-editable-label/gr-editable-label';
 import '../gr-default-editor/gr-default-editor';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-editor-view_html';
 import {
   GerritNav,
   GenerateUrlEditViewParameters,
 } from '../../core/gr-navigation/gr-navigation';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   PatchSetNum,
   EditPreferencesInfo,
@@ -43,10 +40,11 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {changeIsMerged, changeIsAbandoned} from '../../../utils/change-util';
-import {GrButton} from '../../shared/gr-button/gr-button';
-import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
-import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {addShortcut, Modifier} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -57,23 +55,8 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
-// Used within the tests
-export interface GrEditorView {
-  $: {
-    close: GrButton;
-    editorEndpoint: GrEndpointDecorator;
-    file: GrDefaultEditor;
-    publish: GrButton;
-    save: GrButton;
-  };
-}
-
 @customElement('gr-editor-view')
-export class GrEditorView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditorView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -86,47 +69,39 @@
    * @event show-alert
    */
 
-  @property({type: Object, observer: '_paramsChanged'})
+  @property({type: Object})
   params?: GenerateUrlEditViewParameters;
 
-  @property({type: Object, observer: '_editChange'})
-  _change?: ParsedChangeInfo;
+  // private but used in test
+  @state() change?: ParsedChangeInfo;
 
-  @property({type: Number})
-  _changeNum?: NumericChangeId;
+  // private but used in test
+  @state() changeNum?: NumericChangeId;
 
-  @property({type: String})
-  _patchNum?: PatchSetNum;
+  // private but used in test
+  @state() patchNum?: PatchSetNum;
 
-  @property({type: String})
-  _path?: string;
+  // private but used in test
+  @state() path?: string;
 
-  @property({type: String})
-  _type?: string;
+  // private but used in test
+  @state() type?: string;
 
-  @property({type: String})
-  _content?: string;
+  // private but used in test
+  @state() content?: string;
 
-  @property({type: String})
-  _newContent = '';
+  // private but used in test
+  @state() newContent = '';
 
-  @property({type: Boolean})
-  _saving = false;
+  // private but used in test
+  @state() saving = false;
 
-  @property({type: Boolean})
-  _successfulSave = false;
+  // private but used in test
+  @state() successfulSave = false;
 
-  @property({
-    type: Boolean,
-    computed: '_computeSaveDisabled(_content, _newContent, _saving)',
-  })
-  _saveDisabled = true;
+  @state() private editPrefs?: EditPreferencesInfo;
 
-  @property({type: Object})
-  _prefs?: EditPreferencesInfo;
-
-  @property({type: Number})
-  _lineNum?: number;
+  @state() private lineNum?: number;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -134,6 +109,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly userModel = getAppContext().userModel;
+
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
@@ -143,23 +120,27 @@
   constructor() {
     super();
     this.addEventListener('content-change', e => {
-      this._handleContentChange(e as CustomEvent<{value: string}>);
+      this.handleContentChange(e as CustomEvent<{value: string}>);
     });
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => {
+        this.editPrefs = editPreferences;
+      }
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getEditPrefs().then(prefs => {
-      this._prefs = prefs;
-    });
     this.cleanups.push(
       addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
-        this._handleSaveShortcut()
+        this.handleSaveShortcut()
       )
     );
     this.cleanups.push(
       addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, () =>
-        this._handleSaveShortcut()
+        this.handleSaveShortcut()
       )
     );
   }
@@ -171,99 +152,241 @@
     super.disconnectedCallback();
   }
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          background-color: var(--view-background-color);
+        }
+        .stickyHeader {
+          background-color: var(--edit-mode-background-color);
+          border-bottom: 1px var(--border-color) solid;
+          position: sticky;
+          top: 0;
+          z-index: 1;
+        }
+        header {
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+          justify-content: space-between;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        header gr-editable-label {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        header gr-editable-label::part(label) {
+          text-overflow: initial;
+          white-space: initial;
+          word-break: break-all;
+        }
+        header gr-editable-label::part(input-container) {
+          margin-top: var(--spacing-l);
+        }
+        .textareaWrapper {
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          margin: var(--spacing-l);
+        }
+        .textareaWrapper .editButtons {
+          display: none;
+        }
+        .controlGroup {
+          align-items: center;
+          display: flex;
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        .rightControls {
+          justify-content: flex-end;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
+  }
+
+  private renderHeader() {
+    return html`
+      <div class="stickyHeader">
+        <header>
+          <span class="controlGroup">
+            <span>Edit mode</span>
+            <span class="separator"></span>
+            <gr-editable-label
+              labelText="File path"
+              .value=${this.path}
+              placeholder="File path..."
+              @changed=${this.handlePathChanged}
+            ></gr-editable-label>
+          </span>
+          <span class="controlGroup rightControls">
+            <gr-button id="close" link="" @click=${this.handleCloseTap}
+              >Cancel</gr-button
+            >
+            <gr-button
+              id="save"
+              ?disabled=${this.computeSaveDisabled()}
+              primary=""
+              link=""
+              title="Save and Close the file"
+              @click=${this.handleSaveTap}
+              >Save</gr-button
+            >
+            <gr-button
+              id="publish"
+              link=""
+              primary=""
+              title="Publish your edit. A new patchset will be created."
+              @click=${this.handlePublishTap}
+              ?disabled=${this.computeSaveDisabled()}
+              >Save & Publish</gr-button
+            >
+          </span>
+        </header>
+      </div>
+    `;
+  }
+
+  private renderEndpoint() {
+    return html`
+      <div class="textareaWrapper">
+        <gr-endpoint-decorator id="editorEndpoint" name="editor">
+          <gr-endpoint-param
+            name="fileContent"
+            .value=${this.newContent}
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="prefs"
+            .value=${this.editPrefs}
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="fileType"
+            .value=${this.type}
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="lineNum"
+            .value=${this.lineNum}
+          ></gr-endpoint-param>
+          <gr-default-editor
+            id="file"
+            .fileContent=${this.newContent}
+          ></gr-default-editor>
+        </gr-endpoint-decorator>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+
+    if (changedProperties.has('change')) {
+      this.navigateToChangeIfEdit();
+    }
+
+    if (changedProperties.has('change') || changedProperties.has('type')) {
+      this.navigateToChangeIfEditType();
+    }
+  }
+
   get storageKey() {
-    return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
+    return `c${this.changeNum}_ps${this.patchNum}_${this.path}`;
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
+  // private but used in test
+  paramsChanged() {
+    if (!this.params) return;
 
-  _getEditPrefs() {
-    return this.restApiService.getEditPreferences();
-  }
-
-  _paramsChanged(value: GenerateUrlEditViewParameters) {
-    if (value.view !== GerritNav.View.EDIT) {
+    if (this.params.view !== GerritNav.View.EDIT) {
       return;
     }
 
-    this._changeNum = value.changeNum;
-    this._path = value.path;
-    this._patchNum = value.patchNum || (EditPatchSetNum as PatchSetNum);
-    this._lineNum =
-      typeof value.lineNum === 'string' ? Number(value.lineNum) : value.lineNum;
+    this.changeNum = this.params.changeNum;
+    this.path = this.params.path;
+    this.patchNum = this.params.patchNum || (EditPatchSetNum as PatchSetNum);
+    this.lineNum =
+      typeof this.params.lineNum === 'string'
+        ? Number(this.params.lineNum)
+        : this.params.lineNum;
 
     // NOTE: This may be called before attachment (e.g. while parentElement is
     // null). Fire title-change in an async so that, if attachment to the DOM
     // has been queued, the event can bubble up to the handler in gr-app.
     setTimeout(() => {
-      const title = `Editing ${computeTruncatedPath(value.path)}`;
+      if (!this.params) return;
+      const title = `Editing ${computeTruncatedPath(this.params.path)}`;
       fireTitleChange(this, title);
     });
 
     const promises = [];
 
-    promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(
-      this._getFileData(this._changeNum, this._path, this._patchNum)
-    );
+    promises.push(this.getChangeDetail(this.changeNum));
+    promises.push(this.getFileData(this.changeNum, this.path, this.patchNum));
     return Promise.all(promises);
   }
 
-  async _getChangeDetail(changeNum: NumericChangeId) {
-    this._change = await this.restApiService.getChangeDetail(changeNum);
+  private async getChangeDetail(changeNum: NumericChangeId) {
+    this.change = await this.restApiService.getChangeDetail(changeNum);
   }
 
-  _editChange(value?: ParsedChangeInfo | null) {
-    if (!value) return;
-    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
+  private navigateToChangeIfEdit() {
+    if (!this.change) return;
+    if (!changeIsMerged(this.change) && !changeIsAbandoned(this.change)) return;
     fireAlert(
       this,
       'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
     );
-    GerritNav.navigateToChange(value);
+    GerritNav.navigateToChange(this.change);
   }
 
-  @observe('_change', '_type')
-  _editType(change?: ParsedChangeInfo | null, type?: string) {
-    if (!change || !type || !type.startsWith('image/')) return;
+  private navigateToChangeIfEditType() {
+    if (!this.change || !this.type || !this.type.startsWith('image/')) return;
 
     // Prevent editing binary files
     fireAlert(this, 'You cannot edit binary files within the inline editor.');
-    GerritNav.navigateToChange(change);
+    GerritNav.navigateToChange(this.change);
   }
 
-  _handlePathChanged(e: CustomEvent<string>) {
+  // private but used in test
+  async handlePathChanged(e: CustomEvent<string>): Promise<void> {
     // TODO(TS) could be cleaned up, it was added for type requirements
-    if (this._changeNum === undefined || !this._path) {
-      return Promise.reject(new Error('changeNum or path undefined'));
+    if (this.changeNum === undefined || !this.path) {
+      throw new Error('changeNum or path undefined');
     }
     const path = e.detail;
-    if (path === this._path) {
-      return Promise.resolve();
-    }
-    return this.restApiService
-      .renameFileInChangeEdit(this._changeNum, this._path, path)
-      .then(res => {
-        if (!res || !res.ok) {
-          return;
-        }
+    if (path === this.path) return;
+    const res = await this.restApiService.renameFileInChangeEdit(
+      this.changeNum,
+      this.path,
+      path
+    );
+    if (!res?.ok) return;
 
-        this._successfulSave = true;
-        this._viewEditInChangeView();
-      });
+    this.successfulSave = true;
+    this.viewEditInChangeView();
   }
 
-  _viewEditInChangeView() {
-    if (this._change)
-      GerritNav.navigateToChange(this._change, {
+  // private but used in test
+  viewEditInChangeView() {
+    if (this.change)
+      GerritNav.navigateToChange(this.change, {
         isEdit: true,
         forceReload: true,
       });
   }
 
-  _getFileData(
+  // private but used in test
+  getFileData(
     changeNum: NumericChangeId,
     path: string,
     patchNum?: PatchSetNum
@@ -284,89 +407,87 @@
         ) {
           fireAlert(this, RESTORED_MESSAGE);
 
-          this._newContent = storedContent.message;
+          this.newContent = storedContent.message;
         } else {
-          this._newContent = content;
+          this.newContent = content;
         }
-        this._content = content;
+        this.content = content;
 
         // A non-ok response may result if the file does not yet exist.
         // The `type` field of the response is only valid when the file
         // already exists.
         if (res && res.ok && res.type) {
-          this._type = res.type;
+          this.type = res.type;
         } else {
-          this._type = '';
+          this.type = '';
         }
       });
   }
 
-  _saveEdit() {
-    if (this._changeNum === undefined || !this._path) {
+  // private but used in test
+  saveEdit() {
+    if (this.changeNum === undefined || !this.path) {
       return Promise.reject(new Error('changeNum or path undefined'));
     }
-    this._saving = true;
-    this._showAlert(SAVING_MESSAGE);
+    this.saving = true;
+    this.showAlert(SAVING_MESSAGE);
     this.storage.eraseEditableContentItem(this.storageKey);
-    if (!this._newContent)
+    if (!this.newContent)
       return Promise.reject(new Error('new content undefined'));
     return this.restApiService
-      .saveChangeEdit(this._changeNum, this._path, this._newContent)
+      .saveChangeEdit(this.changeNum, this.path, this.newContent)
       .then(res => {
-        this._saving = false;
-        this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
+        this.saving = false;
+        this.showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
         if (!res.ok) {
           return res;
         }
 
-        this._content = this._newContent;
-        this._successfulSave = true;
+        this.content = this.newContent;
+        this.successfulSave = true;
         return res;
       });
   }
 
-  _showAlert(message: string) {
+  // private but used in test
+  showAlert(message: string) {
     fireAlert(this, message);
   }
 
-  _computeSaveDisabled(
-    content?: string,
-    newContent?: string,
-    saving?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if ([content, newContent, saving].includes(undefined)) {
+  computeSaveDisabled() {
+    if ([this.content, this.newContent, this.saving].includes(undefined)) {
       return true;
     }
 
-    if (saving) {
+    if (this.saving) {
       return true;
     }
-    return content === newContent;
+    return this.content === this.newContent;
   }
 
-  _handleCloseTap() {
+  // private but used in test
+  handleCloseTap = () => {
     // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
-    this._viewEditInChangeView();
-  }
+    this.viewEditInChangeView();
+  };
 
-  _handleSaveTap() {
-    this._saveEdit().then(res => {
-      if (res.ok) this._viewEditInChangeView();
+  private handleSaveTap = () => {
+    this.saveEdit().then(res => {
+      if (res.ok) this.viewEditInChangeView();
     });
-  }
+  };
 
-  _handlePublishTap() {
-    assertIsDefined(this._changeNum, '_changeNum');
+  private handlePublishTap = () => {
+    assertIsDefined(this.changeNum, 'changeNum');
 
-    const changeNum = this._changeNum;
-    this._saveEdit().then(() => {
+    const changeNum = this.changeNum;
+    this.saveEdit().then(() => {
       const handleError: ErrorCallback = response => {
-        this._showAlert(PUBLISH_FAILED_MSG);
+        this.showAlert(PUBLISH_FAILED_MSG);
         this.reporting.error(new Error(response?.statusText));
       };
 
-      this._showAlert(PUBLISHING_EDIT_MSG);
+      this.showAlert(PUBLISHING_EDIT_MSG);
 
       this.restApiService
         .executeChangeAction(
@@ -378,19 +499,19 @@
           handleError
         )
         .then(() => {
-          assertIsDefined(this._change, '_change');
-          GerritNav.navigateToChange(this._change, {forceReload: true});
+          assertIsDefined(this.change, 'change');
+          GerritNav.navigateToChange(this.change, {forceReload: true});
         });
     });
-  }
+  };
 
-  _handleContentChange(e: CustomEvent<{value: string}>) {
+  private handleContentChange(e: CustomEvent<{value: string}>) {
     this.storeTask = debounce(
       this.storeTask,
       () => {
         const content = e.detail.value;
         if (content) {
-          this.set('_newContent', e.detail.value);
+          this.newContent = e.detail.value;
           this.storage.setEditableContentItem(this.storageKey, content);
         } else {
           this.storage.eraseEditableContentItem(this.storageKey);
@@ -400,9 +521,10 @@
     );
   }
 
-  _handleSaveShortcut() {
-    if (!this._saveDisabled) {
-      this._saveEdit();
+  // private but used in test
+  handleSaveShortcut() {
+    if (!this.computeSaveDisabled()) {
+      this.saveEdit();
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
deleted file mode 100644
index b577db3..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--view-background-color);
-    }
-    .stickyHeader {
-      background-color: var(--edit-mode-background-color);
-      border-bottom: 1px var(--border-color) solid;
-      position: sticky;
-      top: 0;
-      z-index: 1;
-    }
-    header {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    header gr-editable-label {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    header gr-editable-label::part(label) {
-      text-overflow: initial;
-      white-space: initial;
-      word-break: break-all;
-    }
-    header gr-editable-label::part(input-container) {
-      margin-top: var(--spacing-l);
-    }
-    .textareaWrapper {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin: var(--spacing-l);
-    }
-    .textareaWrapper .editButtons {
-      display: none;
-    }
-    .controlGroup {
-      align-items: center;
-      display: flex;
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    .rightControls {
-      justify-content: flex-end;
-    }
-  </style>
-  <div class="stickyHeader">
-    <header>
-      <span class="controlGroup">
-        <span>Edit mode</span>
-        <span class="separator"></span>
-        <gr-editable-label
-          label-text="File path"
-          value="[[_path]]"
-          placeholder="File path..."
-          on-changed="_handlePathChanged"
-        ></gr-editable-label>
-      </span>
-      <span class="controlGroup rightControls">
-        <gr-button id="close" link="" on-click="_handleCloseTap"
-          >Cancel</gr-button
-        >
-        <gr-button
-          id="save"
-          disabled$="[[_saveDisabled]]"
-          primary=""
-          link=""
-          title="Save and Close the file"
-          on-click="_handleSaveTap"
-          >Save</gr-button
-        >
-        <gr-button
-          id="publish"
-          link=""
-          primary=""
-          title="Publish your edit. A new patchset will be created."
-          on-click="_handlePublishTap"
-          disabled$="[[_saveDisabled]]"
-          >Save & Publish</gr-button
-        >
-      </span>
-    </header>
-  </div>
-  <div class="textareaWrapper">
-    <gr-endpoint-decorator id="editorEndpoint" name="editor">
-      <gr-endpoint-param
-        name="fileContent"
-        value="[[_newContent]]"
-      ></gr-endpoint-param>
-      <gr-endpoint-param name="prefs" value="[[_prefs]]"></gr-endpoint-param>
-      <gr-endpoint-param name="fileType" value="[[_type]]"></gr-endpoint-param>
-      <gr-endpoint-param
-        name="lineNum"
-        value="[[_lineNum]]"
-      ></gr-endpoint-param>
-      <gr-default-editor
-        id="file"
-        file-content="[[_newContent]]"
-      ></gr-default-editor>
-    </gr-endpoint-decorator>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 07f3851..a449e53 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -20,7 +20,12 @@
 import {GrEditorView} from './gr-editor-view';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {HttpMethod} from '../../../constants/constants';
-import {mockPromise, stubRestApi, stubStorage} from '../../../test/test-utils';
+import {
+  mockPromise,
+  query,
+  stubRestApi,
+  stubStorage,
+} from '../../../test/test-utils';
 import {
   EditPatchSetNum,
   NumericChangeId,
@@ -31,6 +36,9 @@
   createGenerateUrlEditViewParameters,
 } from '../../../test/test-data-generators';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 const basicFixture = fixtureFromElement('gr-editor-view');
 
@@ -42,32 +50,33 @@
   let changeDetailStub: sinon.SinonStub;
   let navigateStub: sinon.SinonStub;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
     savePathStub = stubRestApi('renameFileInChangeEdit');
     saveFileStub = stubRestApi('saveChangeEdit');
     changeDetailStub = stubRestApi('getChangeDetail');
-    navigateStub = sinon.stub(element, '_viewEditInChangeView');
+    navigateStub = sinon.stub(element, 'viewEditInChangeView');
+    await element.updateComplete;
   });
 
-  suite('_paramsChanged', () => {
-    test('good params proceed', () => {
+  suite('paramsChanged', () => {
+    test('good params proceed', async () => {
       changeDetailStub.returns(Promise.resolve({}));
-      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
-        element._content = 'text';
-        element._newContent = 'text';
-        element._type = 'application/octet-stream';
+      const fileStub = sinon.stub(element, 'getFileData').callsFake(() => {
+        element.content = 'text';
+        element.newContent = 'text';
+        element.type = 'application/octet-stream';
         return Promise.resolve();
       });
 
-      const promises = element._paramsChanged({
-        ...createGenerateUrlEditViewParameters(),
-      });
+      element.params = {...createGenerateUrlEditViewParameters()};
+      const promises = element.paramsChanged();
 
-      flush();
+      await element.updateComplete;
+
       const changeNum = 42 as NumericChangeId;
-      assert.equal(element._changeNum, changeNum);
-      assert.equal(element._path, 'foo/bar.baz');
+      assert.equal(element.changeNum, changeNum);
+      assert.equal(element.path, 'foo/bar.baz');
       assert.deepEqual(changeDetailStub.lastCall.args[0], changeNum);
       assert.deepEqual(fileStub.lastCall.args, [
         changeNum,
@@ -76,47 +85,46 @@
       ]);
 
       return promises?.then(() => {
-        assert.equal(element._content, 'text');
-        assert.equal(element._newContent, 'text');
-        assert.equal(element._type, 'application/octet-stream');
+        assert.equal(element.content, 'text');
+        assert.equal(element.newContent, 'text');
+        assert.equal(element.type, 'application/octet-stream');
       });
     });
   });
 
   test('edit file path', () => {
-    element._changeNum = 42 as NumericChangeId;
-    element._path = 'foo/bar.baz';
+    element.changeNum = 42 as NumericChangeId;
+    element.path = 'foo/bar.baz';
     savePathStub.onFirstCall().returns(Promise.resolve({}));
     savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
 
     // Calling with the same path should not navigate.
     return element
-      ._handlePathChanged(new CustomEvent('change', {detail: 'foo/bar.baz'}))
+      .handlePathChanged(new CustomEvent('change', {detail: 'foo/bar.baz'}))
       .then(() => {
         assert.isFalse(savePathStub.called);
         // !ok response
         element
-          ._handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
+          .handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
           .then(() => {
             assert.isTrue(savePathStub.called);
             assert.isFalse(navigateStub.called);
             // ok response
             element
-              ._handlePathChanged(
-                new CustomEvent('change', {detail: 'newPath'})
-              )
+              .handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
               .then(() => {
                 assert.isTrue(navigateStub.called);
-                assert.isTrue(element._successfulSave);
+                assert.isTrue(element.successfulSave);
               });
           });
       });
   });
 
-  test('reacts to content-change event', () => {
+  test('reacts to content-change event', async () => {
     const storageStub = stubStorage('setEditableContentItem');
-    element._newContent = 'test';
-    element.$.editorEndpoint.dispatchEvent(
+    element.newContent = 'test';
+    await element.updateComplete;
+    query<GrEndpointDecorator>(element, '#editorEndpoint')!.dispatchEvent(
       new CustomEvent('content-change', {
         bubbles: true,
         composed: true,
@@ -124,9 +132,9 @@
       })
     );
     element.storeTask?.flush();
-    flush();
+    await element.updateComplete;
 
-    assert.equal(element._newContent, 'new content value');
+    assert.equal(element.newContent, 'new content value');
     assert.isTrue(storageStub.called);
     assert.equal(storageStub.lastCall.args[1], 'new content value');
   });
@@ -135,40 +143,50 @@
     const originalText = 'file text';
     const newText = 'file text changed';
 
-    setup(() => {
-      element._changeNum = 42 as NumericChangeId;
-      element._path = 'foo/bar.baz';
-      element._content = originalText;
-      element._newContent = originalText;
-      flush();
+    setup(async () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.path = 'foo/bar.baz';
+      element.content = originalText;
+      element.newContent = originalText;
+      await element.updateComplete;
     });
 
     test('initial load', () => {
-      assert.equal(element.$.file.fileContent, originalText);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.equal(
+        query<GrDefaultEditor>(element, '#file')!.fileContent,
+        originalText
+      );
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
     });
 
-    test('file modification and save, !ok response', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
+    test('file modification and save, !ok response', async () => {
+      const saveSpy = sinon.spy(element, 'saveEdit');
       const eraseStub = stubStorage('eraseEditableContentItem');
-      const alertStub = sinon.stub(element, '_showAlert');
+      const alertStub = sinon.stub(element, 'showAlert');
       saveFileStub.returns(Promise.resolve({ok: false}));
-      element._newContent = newText;
-      flush();
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-      assert.isFalse(element._saving);
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
+      assert.isFalse(element.saving);
 
-      MockInteractions.tap(element.$.save);
+      MockInteractions.tap(query<GrButton>(element, '#save')!);
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.isTrue(element.saving);
+      await element.updateComplete;
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
       return saveSpy.lastCall.returnValue.then(() => {
         assert.isTrue(saveFileStub.called);
         assert.isTrue(eraseStub.called);
-        assert.isFalse(element._saving);
+        assert.isFalse(element.saving);
         assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
         assert.deepEqual(saveFileStub.lastCall.args, [
           42 as NumericChangeId,
@@ -176,65 +194,81 @@
           newText,
         ]);
         assert.isFalse(navigateStub.called);
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-        assert.notEqual(element._content, element._newContent);
+        assert.isFalse(
+          query<GrButton>(element, '#save')!.hasAttribute('disabled')
+        );
+        assert.notEqual(element.content, element.newContent);
       });
     });
 
-    test('file modification and save', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
+    test('file modification and save', async () => {
+      const saveSpy = sinon.spy(element, 'saveEdit');
+      const alertStub = sinon.stub(element, 'showAlert');
       saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element.saving);
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
-      MockInteractions.tap(element.$.save);
+      MockInteractions.tap(query<GrButton>(element, '#save')!);
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.isTrue(element.saving);
+      await element.updateComplete;
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
       return saveSpy.lastCall.returnValue.then(() => {
         assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
+        assert.isFalse(element.saving);
         assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
+        assert.isTrue(
+          query<GrButton>(element, '#save')!.hasAttribute('disabled')
+        );
+        assert.equal(element.content, element.newContent);
+        assert.isTrue(element.successfulSave);
         assert.isTrue(navigateStub.called);
       });
     });
 
-    test('file modification and publish', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
+    test('file modification and publish', async () => {
+      const saveSpy = sinon.spy(element, 'saveEdit');
+      const alertStub = sinon.stub(element, 'showAlert');
       const changeActionsStub = stubRestApi('executeChangeAction');
       saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element.saving);
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
-      MockInteractions.tap(element.$.publish);
+      MockInteractions.tap(query<GrButton>(element, '#publish')!);
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.isTrue(element.saving);
+      await element.updateComplete;
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
       return saveSpy.lastCall.returnValue.then(() => {
         assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
+        assert.isFalse(element.saving);
 
         assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
         assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
 
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
+        assert.isTrue(
+          query<GrButton>(element, '#save')!.hasAttribute('disabled')
+        );
+        assert.equal(element.content, element.newContent);
+        assert.isTrue(element.successfulSave);
         assert.isFalse(navigateStub.called);
 
         const args = changeActionsStub.lastCall.args;
@@ -244,25 +278,27 @@
       });
     });
 
-    test('file modification and close', () => {
-      const closeSpy = sinon.spy(element, '_handleCloseTap');
-      element._newContent = newText;
-      flush();
+    test('file modification and close', async () => {
+      const closeSpy = sinon.spy(element, 'handleCloseTap');
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
-      MockInteractions.tap(element.$.close);
+      MockInteractions.tap(query<GrButton>(element, '#close')!);
       assert.isTrue(closeSpy.called);
       assert.isFalse(saveFileStub.called);
       assert.isTrue(navigateStub.called);
     });
   });
 
-  suite('_getFileData', () => {
+  suite('getFileData', () => {
     setup(() => {
-      element._newContent = 'initial';
-      element._content = 'initial';
-      element._type = 'initial';
+      element.newContent = 'initial';
+      element.content = 'initial';
+      element.type = 'initial';
       stubStorage('getEditableContentItem').returns(null);
     });
 
@@ -277,15 +313,15 @@
 
       // Ensure no data is set with a bad response.
       return element
-        ._getFileData(
+        .getFileData(
           1 as NumericChangeId,
           'test/path',
           EditPatchSetNum as PatchSetNum
         )
         .then(() => {
-          assert.equal(element._newContent, 'new content');
-          assert.equal(element._content, 'new content');
-          assert.equal(element._type, 'text/javascript');
+          assert.equal(element.newContent, 'new content');
+          assert.equal(element.content, 'new content');
+          assert.equal(element.type, 'text/javascript');
         });
     });
 
@@ -296,15 +332,15 @@
 
       // Ensure no data is set with a bad response.
       return element
-        ._getFileData(
+        .getFileData(
           1 as NumericChangeId,
           'test/path',
           EditPatchSetNum as PatchSetNum
         )
         .then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, '');
+          assert.equal(element.newContent, '');
+          assert.equal(element.content, '');
+          assert.equal(element.type, '');
         });
     });
 
@@ -318,15 +354,15 @@
       );
 
       return element
-        ._getFileData(
+        .getFileData(
           1 as NumericChangeId,
           'test/path',
           EditPatchSetNum as PatchSetNum
         )
         .then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, 'text/javascript');
+          assert.equal(element.newContent, '');
+          assert.equal(element.content, '');
+          assert.equal(element.type, 'text/javascript');
         });
     });
 
@@ -336,37 +372,37 @@
       );
 
       return element
-        ._getFileData(
+        .getFileData(
           1 as NumericChangeId,
           'test/path',
           EditPatchSetNum as PatchSetNum
         )
         .then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, '');
+          assert.equal(element.newContent, '');
+          assert.equal(element.content, '');
+          assert.equal(element.type, '');
         });
     });
   });
 
-  test('_showAlert', async () => {
+  test('showAlert', async () => {
     const promise = mockPromise();
     element.addEventListener('show-alert', e => {
-      assert.deepEqual(e.detail, {message: 'test message'});
+      assert.deepEqual(e.detail, {message: 'test message', showDismiss: true});
       assert.isTrue(e.bubbles);
       promise.resolve();
     });
 
-    element._showAlert('test message');
+    element.showAlert('test message');
     await promise;
   });
 
-  test('_viewEditInChangeView', () => {
-    element._change = createChangeViewChange();
+  test('viewEditInChangeView', () => {
+    element.change = createChangeViewChange();
     navigateStub.restore();
     const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._patchNum = EditPatchSetNum;
-    element._viewEditInChangeView();
+    element.patchNum = EditPatchSetNum;
+    element.viewEditInChangeView();
     assert.equal(navStub.lastCall.args[1]!.patchNum, undefined);
     assert.equal(navStub.lastCall.args[1]!.isEdit, true);
   });
@@ -375,38 +411,38 @@
     // Used as the spy on the handler for each entry in keyBindings.
     let handleSpy: sinon.SinonSpy;
 
-    suite('_handleSaveShortcut', () => {
+    suite('handleSaveShortcut', () => {
       let saveStub: sinon.SinonStub;
       setup(() => {
-        handleSpy = sinon.spy(element, '_handleSaveShortcut');
-        saveStub = sinon.stub(element, '_saveEdit');
+        handleSpy = sinon.spy(element, 'handleSaveShortcut');
+        saveStub = sinon.stub(element, 'saveEdit');
       });
 
-      test('save enabled', () => {
-        element._content = '';
-        element._newContent = '_test';
+      test('save enabled', async () => {
+        element.content = '';
+        element.newContent = '_test';
         MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
+        await element.updateComplete;
 
         assert.isTrue(handleSpy.calledOnce);
         assert.isTrue(saveStub.calledOnce);
 
         MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
+        await element.updateComplete;
 
         assert.equal(handleSpy.callCount, 2);
         assert.equal(saveStub.callCount, 2);
       });
 
-      test('save disabled', () => {
+      test('save disabled', async () => {
         MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
+        await element.updateComplete;
 
         assert.isTrue(handleSpy.calledOnce);
         assert.isFalse(saveStub.called);
 
         MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
+        await element.updateComplete;
 
         assert.equal(handleSpy.callCount, 2);
         assert.isFalse(saveStub.called);
@@ -432,14 +468,14 @@
       element.addEventListener('show-alert', alertStub);
 
       return element
-        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(() => {
-          flush();
+        .getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
+        .then(async () => {
+          await element.updateComplete;
 
           assert.isTrue(alertStub.called);
-          assert.equal(element._newContent, 'pending edit');
-          assert.equal(element._content, 'old content');
-          assert.equal(element._type, 'text/javascript');
+          assert.equal(element.newContent, 'pending edit');
+          assert.equal(element.content, 'old content');
+          assert.equal(element.type, 'text/javascript');
         });
     });
 
@@ -460,21 +496,21 @@
       element.addEventListener('show-alert', alertStub);
 
       return element
-        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(() => {
-          flush();
+        .getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
+        .then(async () => {
+          await element.updateComplete;
 
           assert.isFalse(alertStub.called);
-          assert.equal(element._newContent, 'pending edit');
-          assert.equal(element._content, 'pending edit');
-          assert.equal(element._type, 'text/javascript');
+          assert.equal(element.newContent, 'pending edit');
+          assert.equal(element.content, 'pending edit');
+          assert.equal(element.type, 'text/javascript');
         });
     });
 
     test('storage key computation', () => {
-      element._changeNum = 1 as NumericChangeId;
-      element._patchNum = 1 as PatchSetNum;
-      element._path = 'test';
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.path = 'test';
       assert.equal(element.storageKey, 'c1_ps1_test');
     });
   });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index f07daab..16e959a 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../styles/shared-styles';
+
 import '../styles/themes/app-theme';
 import '../styles/themes/dark-theme';
 import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme';
@@ -26,7 +26,6 @@
 import './core/gr-error-manager/gr-error-manager';
 import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog';
 import './core/gr-main-header/gr-main-header';
-import './core/gr-router/gr-router';
 import './core/gr-smart-search/gr-smart-search';
 import './diff/gr-diff-view/gr-diff-view';
 import './edit/gr-editor-view/gr-editor-view';
@@ -38,24 +37,12 @@
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-app-element_html';
 import {getBaseUrl} from '../utils/url-util';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {Shortcut} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
-import {flush} from '@polymer/polymer/lib/utils/flush';
-import {customElement, observe, property} from '@polymer/decorators';
 import {GrRouter} from './core/gr-router/gr-router';
-import {
-  AccountDetailInfo,
-  ElementPropertyDeepChange,
-  ServerInfo,
-} from '../types/common';
+import {AccountDetailInfo, ServerInfo} from '../types/common';
 import {
   constructServerErrorMsg,
   GrErrorManager,
@@ -65,6 +52,8 @@
 import {
   AppElementJustRegisteredParams,
   AppElementParams,
+  AppElementPluginScreenParams,
+  AppElementSearchParam,
   isAppElementJustRegisteredParams,
 } from './gr-app-types';
 import {GrMainHeader} from './core/gr-main-header/gr-main-header';
@@ -78,14 +67,19 @@
   TitleChangeEventDetail,
   ValueChangedEvent,
 } from '../types/events';
-import {ChangeListViewState, ViewState} from '../types/types';
+import {ChangeListViewState, ChangeViewState, ViewState} from '../types/types';
 import {GerritView} from '../services/router/router-model';
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
-import {assertIsDefined} from '../utils/common-util';
-import {listen} from '../services/shortcuts/shortcuts-service';
-import {resolve, DIPolymerElement} from '../models/dependency';
+import {resolve} from '../models/dependency';
 import {browserModelToken} from '../models/browser/browser-model';
+import {sharedStyles} from '../styles/shared-styles';
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {ShortcutController} from './lit/shortcut-controller';
+import {cache} from 'lit/directives/cache';
+import {assertIsDefined} from '../utils/common-util';
+import './gr-css-mixins';
 
 interface ErrorInfo {
   text: string;
@@ -93,121 +87,94 @@
   moreInfo?: string;
 }
 
-export interface GrAppElement {
-  $: {
-    router: GrRouter;
-    errorManager: GrErrorManager;
-    errorView: HTMLDivElement;
-    mainHeader: GrMainHeader;
-  };
-}
-
-type DomIf = PolymerElement & {
-  restamp: boolean;
-};
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(DIPolymerElement);
-
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
-export class GrAppElement extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAppElement extends LitElement {
   /**
    * Fired when the URL location changes.
    *
    * @event location-change
    */
 
+  @query('#errorManager') errorManager?: GrErrorManager;
+
+  @query('#errorView') errorView?: HTMLDivElement;
+
+  @query('#mainHeader') mainHeader?: GrMainHeader;
+
+  @query('#registrationOverlay') registrationOverlay?: GrOverlay;
+
+  @query('#registrationDialog') registrationDialog?: GrRegistrationDialog;
+
+  @query('#keyboardShortcuts') keyboardShortcuts?: GrOverlay;
+
+  @query('gr-settings-view') settingdView?: GrSettingsView;
+
   @property({type: Object})
   params?: AppElementParams;
 
-  @property({type: Object, observer: '_accountChanged'})
-  _account?: AccountDetailInfo;
+  @state() private account?: AccountDetailInfo;
 
-  @property({type: Number})
-  _lastGKeyPressTimestamp: number | null = null;
+  @state() private serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @state() private version?: string;
 
-  @property({type: String})
-  _version?: string;
+  @state() private showChangeListView?: boolean;
 
-  @property({type: Boolean})
-  _showChangeListView?: boolean;
+  @state() private showDashboardView?: boolean;
 
-  @property({type: Boolean})
-  _showDashboardView?: boolean;
+  @state() private showChangeView?: boolean;
 
-  @property({type: Boolean})
-  _showChangeView?: boolean;
+  @state() private showDiffView?: boolean;
 
-  @property({type: Boolean})
-  _showDiffView?: boolean;
+  @state() private showSettingsView?: boolean;
 
-  @property({type: Boolean})
-  _showSettingsView?: boolean;
+  @state() private showAdminView?: boolean;
 
-  @property({type: Boolean})
-  _showAdminView?: boolean;
+  @state() private showCLAView?: boolean;
 
-  @property({type: Boolean})
-  _showCLAView?: boolean;
+  @state() private showEditorView?: boolean;
 
-  @property({type: Boolean})
-  _showEditorView?: boolean;
+  @state() private showPluginScreen?: boolean;
 
-  @property({type: Boolean})
-  _showPluginScreen?: boolean;
+  @state() private showDocumentationSearch?: boolean;
 
-  @property({type: Boolean})
-  _showDocumentationSearch?: boolean;
+  @state() private viewState?: ViewState;
 
-  @property({type: Object})
-  _viewState?: ViewState;
+  @state() private lastError?: ErrorInfo;
 
-  @property({type: Object})
-  _lastError?: ErrorInfo;
+  // private but used in test
+  @state() lastSearchPage?: string;
 
-  @property({type: String})
-  _lastSearchPage?: string;
+  @state() private path?: string;
 
-  @property({type: String})
-  _path?: string;
+  @state() private settingsUrl?: string;
 
-  @property({type: String, computed: '_computePluginScreenName(params)'})
-  _pluginScreenName?: string;
+  @state() private mobileSearch = false;
 
-  @property({type: String})
-  _settingsUrl?: string;
+  @state() private loginUrl = '/login';
 
-  @property({type: Boolean})
-  mobileSearch = false;
+  @state() private loadRegistrationDialog = false;
 
-  @property({type: String})
-  _loginUrl = '/login';
-
-  @property({type: Boolean})
-  loadRegistrationDialog = false;
-
-  @property({type: Boolean})
-  loadKeyboardShortcutsDialog = false;
+  @state() private loadKeyboardShortcutsDialog = false;
 
   // TODO(milutin) - remove once new gr-dialog will do it out of the box
   // This removes footer, header from a11y tree, when a dialog on view
   // (e.g. reply dialog) is open
-  @property({type: Boolean})
-  _footerHeaderAriaHidden = false;
+  @state() private footerHeaderAriaHidden = false;
 
   // TODO(milutin) - remove once new gr-dialog will do it out of the box
   // This removes main page from a11y tree, when a dialog on gr-app-element
   // (e.g. shortcut dialog) is open
-  @property({type: Boolean})
-  _mainAriaHidden = false;
+  @state() private mainAriaHidden = false;
+
+  // Triggers dom-if unsetting/setting restamp behaviour in lit
+  @state() private invalidateChangeViewCache = false;
+
+  // Triggers dom-if unsetting/setting restamp behaviour in lit
+  @state() private invalidateDiffViewCache = false;
+
+  readonly router = new GrRouter();
 
   private reporting = getAppContext().reportingService;
 
@@ -215,58 +182,60 @@
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, _ =>
-        this._showKeyboardShortcuts()
-      ),
-      listen(Shortcut.GO_TO_USER_DASHBOARD, _ => this._goToUserDashboard()),
-      listen(Shortcut.GO_TO_OPENED_CHANGES, _ => this._goToOpenedChanges()),
-      listen(Shortcut.GO_TO_MERGED_CHANGES, _ => this._goToMergedChanges()),
-      listen(Shortcut.GO_TO_ABANDONED_CHANGES, _ =>
-        this._goToAbandonedChanges()
-      ),
-      listen(Shortcut.GO_TO_WATCHED_CHANGES, _ => this._goToWatchedChanges()),
-    ];
-  }
+  private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
     super();
     document.addEventListener(EventType.PAGE_ERROR, e => {
-      this._handlePageError(e);
+      this.handlePageError(e);
     });
     this.addEventListener(EventType.TITLE_CHANGE, e => {
-      this._handleTitleChange(e);
+      this.handleTitleChange(e);
     });
     this.addEventListener(EventType.DIALOG_CHANGE, e => {
-      this._handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
+      this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
     this.addEventListener(EventType.LOCATION_CHANGE, e =>
-      this._handleLocationChange(e)
+      this.handleLocationChange(e)
     );
     this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
-      this.handleRecreateView(GerritView.CHANGE)
+      this.handleRecreateView()
     );
     this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
-      this.handleRecreateView(GerritView.DIFF)
+      this.handleRecreateView()
     );
-    document.addEventListener(EventType.GR_RPC_LOG, e => this._handleRpcLog(e));
+    document.addEventListener(EventType.GR_RPC_LOG, e => this.handleRpcLog(e));
+    this.shortcuts.addAbstract(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, () =>
+      this.showKeyboardShortcuts()
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_USER_DASHBOARD, () =>
+      this.goToUserDashboard()
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_OPENED_CHANGES, () =>
+      this.goToOpenedChanges()
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_MERGED_CHANGES, () =>
+      this.goToMergedChanges()
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_ABANDONED_CHANGES, () =>
+      this.goToAbandonedChanges()
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_WATCHED_CHANGES, () =>
+      this.goToWatchedChanges()
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
     const resizeObserver = this.getBrowserModel().observeWidth();
     resizeObserver.observe(this);
-  }
 
-  override ready() {
-    super.ready();
-    this._updateLoginUrl();
+    this.updateLoginUrl();
     this.reporting.appStarted();
-    this.$.router.start();
+    this.router.start();
 
     this.restApiService.getAccount().then(account => {
-      this._account = account;
+      this.account = account;
       if (account) {
         this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_USER);
       } else {
@@ -274,11 +243,11 @@
       }
     });
     this.restApiService.getConfig().then(config => {
-      this._serverConfig = config;
+      this.serverConfig = config;
     });
     this.restApiService.getVersion().then(version => {
-      this._version = version;
-      this._logWelcome();
+      this.version = version;
+      this.logWelcome();
     });
 
     const isDarkTheme = !!window.localStorage.getItem('dark-theme');
@@ -288,9 +257,9 @@
 
     // Note: this is evaluated here to ensure that it only happens after the
     // router has been initialized. @see Issue 7837
-    this._settingsUrl = GerritNav.getUrlForSettings();
+    this.settingsUrl = GerritNav.getUrlForSettings();
 
-    this._viewState = {
+    this.viewState = {
       changeView: {
         changeNum: null,
         patchRange: null,
@@ -308,85 +277,372 @@
     };
   }
 
-  _accountChanged(account?: AccountDetailInfo) {
-    if (!account) return;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          background-color: var(--background-color-tertiary);
+          display: flex;
+          flex-direction: column;
+          min-height: 100%;
+        }
+        gr-main-header,
+        footer {
+          color: var(--primary-text-color);
+        }
+        gr-main-header {
+          background: var(
+            --header-background,
+            var(--header-background-color, #eee)
+          );
+          padding: var(--header-padding);
+          border-bottom: var(--header-border-bottom);
+          border-image: var(--header-border-image);
+          border-right: 0;
+          border-left: 0;
+          border-top: 0;
+          box-shadow: var(--header-box-shadow);
+          /* Make sure the header is above the main content, to preserve box-shadow
+            visibility. We need 2 here instead of 1, because dropdowns in the
+            header should be shown on top of the sticky diff header, which has a
+            z-index of 1. */
+          z-index: 2;
+        }
+        footer {
+          background: var(
+            --footer-background,
+            var(--footer-background-color, #eee)
+          );
+          border-top: var(--footer-border-top);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m) var(--spacing-l);
+          z-index: 100;
+        }
+        main {
+          flex: 1;
+          padding-bottom: var(--spacing-xxl);
+          position: relative;
+        }
+        .errorView {
+          align-items: center;
+          display: none;
+          flex-direction: column;
+          justify-content: center;
+          position: absolute;
+          top: 0;
+          right: 0;
+          bottom: 0;
+          left: 0;
+        }
+        .errorView.show {
+          display: flex;
+        }
+        .errorEmoji {
+          font-size: 2.6rem;
+        }
+        .errorText,
+        .errorMoreInfo {
+          margin-top: var(--spacing-m);
+        }
+        .errorText {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        .errorMoreInfo {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-css-mixins></gr-css-mixins>
+      <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
+      <gr-main-header
+        id="mainHeader"
+        .searchQuery=${(this.params as AppElementSearchParam)?.query}
+        @mobile-search=${this.mobileSearchToggle}
+        @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
+        .mobileSearchHidden=${!this.mobileSearch}
+        .loginUrl=${this.loginUrl}
+        ?aria-hidden=${this.footerHeaderAriaHidden}
+      >
+      </gr-main-header>
+      <main ?aria-hidden=${this.mainAriaHidden}>
+        ${this.renderMobileSearch()} ${this.renderChangeListView()}
+        ${this.renderDashboardView()} ${this.renderChangeView()}
+        ${this.renderEditorView()} ${this.renderDiffView()}
+        ${this.renderSettingsView()} ${this.renderAdminView()}
+        ${this.renderPluginScreen()} ${this.renderCLAView()}
+        ${this.renderDocumentationSearch()}
+        <div id="errorView" class="errorView">
+          <div class="errorEmoji">${this.lastError?.emoji}</div>
+          <div class="errorText">${this.lastError?.text}</div>
+          <div class="errorMoreInfo">${this.lastError?.moreInfo}</div>
+        </div>
+      </main>
+      <footer ?aria-hidden=${this.footerHeaderAriaHidden}>
+        <div>
+          Powered by
+          <a
+            href="https://www.gerritcodereview.com/"
+            rel="noopener"
+            target="_blank"
+            >Gerrit Code Review</a
+          >
+          (${this.version})
+          <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
+        </div>
+        <div>
+          Press “?” for keyboard shortcuts
+          <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
+        </div>
+      </footer>
+      ${this.renderKeyboardShortcutsDialog()} ${this.renderRegistrationDialog()}
+      <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
+      <gr-error-manager
+        id="errorManager"
+        .loginUrl=${this.loginUrl}
+      ></gr-error-manager>
+      <gr-plugin-host id="plugins" .config=${this.serverConfig}>
+      </gr-plugin-host>
+      <gr-external-style
+        id="externalStyleForAll"
+        name="app-theme"
+      ></gr-external-style>
+      <gr-external-style
+        id="externalStyleForTheme"
+        .name=${this.getThemeEndpoint()}
+      ></gr-external-style>
+    `;
+  }
+
+  private renderMobileSearch() {
+    if (!this.mobileSearch) return nothing;
+    return html`
+      <gr-smart-search
+        id="search"
+        label="Search for changes"
+        .searchQuery=${(this.params as AppElementSearchParam)?.query}
+        .serverConfig=${this.serverConfig}
+      >
+      </gr-smart-search>
+    `;
+  }
+
+  private renderChangeListView() {
+    if (!this.showChangeListView) return nothing;
+    return html`
+      <gr-change-list-view
+        .params=${this.params}
+        .account=${this.account}
+        .viewState=${this.viewState?.changeListView}
+        @view-state-change-list-view-changed=${this.handleViewStateChanged}
+      ></gr-change-list-view>
+    `;
+  }
+
+  private renderDashboardView() {
+    if (!this.showDashboardView) return nothing;
+    return html`
+      <gr-dashboard-view
+        .account=${this.account}
+        .params=${this.params}
+        .viewState=${this.viewState?.dashboardView}
+      ></gr-dashboard-view>
+    `;
+  }
+
+  private renderChangeView() {
+    if (this.invalidateChangeViewCache) {
+      this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
+      return nothing;
+    }
+    return cache(this.showChangeView ? this.changeViewTemplate() : nothing);
+  }
+
+  // Template as not to create duplicates, for renderChangeView() only.
+  private changeViewTemplate() {
+    return html`
+      <gr-change-view
+        .params=${this.params}
+        .viewState=${this.viewState?.changeView}
+        .backPage=${this.lastSearchPage}
+        @view-state-change-view-changed=${this.handleViewStateChangeViewChanged}
+      ></gr-change-view>
+    `;
+  }
+
+  private renderEditorView() {
+    if (!this.showEditorView) return nothing;
+    return html`<gr-editor-view .params=${this.params}></gr-editor-view>`;
+  }
+
+  private renderDiffView() {
+    if (this.invalidateDiffViewCache) {
+      this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
+      return nothing;
+    }
+    return cache(this.showDiffView ? this.diffViewTemplate() : nothing);
+  }
+
+  private diffViewTemplate() {
+    return html`
+      <gr-diff-view
+        .params=${this.params}
+        .changeViewState=${this.viewState?.changeView}
+        @view-state-change-view-changed=${this.handleViewStateChangeViewChanged}
+      ></gr-diff-view>
+    `;
+  }
+
+  private renderSettingsView() {
+    if (!this.showSettingsView) return nothing;
+    return html`
+      <gr-settings-view
+        .params=${this.params}
+        @account-detail-update=${this.handleAccountDetailUpdate}
+      >
+      </gr-settings-view>
+    `;
+  }
+
+  private renderAdminView() {
+    if (!this.showAdminView) return nothing;
+    return html`<gr-admin-view
+      .path=${this.path}
+      .params=${this.params}
+    ></gr-admin-view>`;
+  }
+
+  private renderPluginScreen() {
+    if (!this.showPluginScreen) return nothing;
+    return html`
+      <gr-endpoint-decorator .name=${this.computePluginScreenName()}>
+        <gr-endpoint-param
+          name="token"
+          .value=${(this.params as AppElementPluginScreenParams).screen}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderCLAView() {
+    if (!this.showCLAView) return nothing;
+    return html`<gr-cla-view></gr-cla-view>`;
+  }
+
+  private renderDocumentationSearch() {
+    if (!this.showDocumentationSearch) return nothing;
+    return html`
+      <gr-documentation-search .params=${this.params}></gr-documentation-search>
+    `;
+  }
+
+  private renderKeyboardShortcutsDialog() {
+    if (!this.loadKeyboardShortcutsDialog) return nothing;
+    return html`
+      <gr-overlay
+        id="keyboardShortcuts"
+        with-backdrop=""
+        @iron-overlay-canceled=${this.onOverlayCanceled}
+      >
+        <gr-keyboard-shortcuts-dialog
+          @close=${this.handleKeyboardShortcutDialogClose}
+        ></gr-keyboard-shortcuts-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderRegistrationDialog() {
+    if (!this.loadRegistrationDialog) return nothing;
+    return html`
+      <gr-overlay id="registrationOverlay" with-backdrop="">
+        <gr-registration-dialog
+          id="registrationDialog"
+          .settingsUrl=${this.settingsUrl}
+          @account-detail-update=${this.handleAccountDetailUpdate}
+          @close=${this.handleRegistrationDialogClose}
+        >
+        </gr-registration-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('account')) {
+      this.accountChanged();
+    }
+
+    if (changedProperties.has('params')) {
+      this.viewChanged();
+
+      this.paramsChanged();
+    }
+  }
+
+  private accountChanged() {
+    if (!this.account) return;
 
     // Preferences are cached when a user is logged in; warm them.
     this.restApiService.getPreferences();
     this.restApiService.getDiffPreferences();
     this.restApiService.getEditPreferences();
-    this.$.errorManager.knownAccountId =
-      (this._account && this._account._account_id) || null;
+    if (this.errorManager)
+      this.errorManager.knownAccountId =
+        (this.account && this.account._account_id) || null;
   }
 
   /**
    * Throws away the view and re-creates it. The view itself fires an event, if
    * it wants to be re-created.
    */
-  private handleRecreateView(view: GerritView.DIFF | GerritView.CHANGE) {
-    const isDiff = view === GerritView.DIFF;
-    const domId = isDiff ? '#dom-if-diff-view' : '#dom-if-change-view';
-    const domIf = this.root!.querySelector(domId) as DomIf;
-    assertIsDefined(domIf, '<dom-if> for the view');
-    // The rendering of DomIf is debounced, so just changing _show...View and
-    // restamp properties back and forth won't work. That is why we are using
-    // timeouts.
-    // The first timeout is needed, because the _viewChanged() observer also
-    // affects _show...View and would change _show...View=false directly back to
-    // _show...View=true.
-    setTimeout(() => {
-      this._showChangeView = false;
-      this._showDiffView = false;
-      domIf.restamp = true;
-      setTimeout(() => {
-        this._showChangeView = this.params?.view === GerritView.CHANGE;
-        this._showDiffView = this.params?.view === GerritView.DIFF;
-        domIf.restamp = false;
-      }, 1);
-    }, 1);
+  private handleRecreateView() {
+    this.invalidateChangeViewCache = true;
+    this.invalidateDiffViewCache = true;
   }
 
-  @observe('params.*')
-  _viewChanged() {
+  private async viewChanged() {
     const view = this.params?.view;
-    this.$.errorView.classList.remove('show');
-    this._showChangeListView = view === GerritView.SEARCH;
-    this._showDashboardView = view === GerritView.DASHBOARD;
-    this._showChangeView = view === GerritView.CHANGE;
-    this._showDiffView = view === GerritView.DIFF;
-    this._showSettingsView = view === GerritView.SETTINGS;
-    // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
-    this._showAdminView =
+    this.errorView?.classList.remove('show');
+    this.showChangeListView = view === GerritView.SEARCH;
+    this.showDashboardView = view === GerritView.DASHBOARD;
+    this.showChangeView = view === GerritView.CHANGE;
+    this.showDiffView = view === GerritView.DIFF;
+    this.showSettingsView = view === GerritView.SETTINGS;
+    // showAdminView must be in sync with the gr-admin-view AdminViewParams type
+    this.showAdminView =
       view === GerritView.ADMIN ||
       view === GerritView.GROUP ||
       view === GerritView.REPO;
-    this._showCLAView = view === GerritView.AGREEMENTS;
-    this._showEditorView = view === GerritView.EDIT;
+    this.showCLAView = view === GerritView.AGREEMENTS;
+    this.showEditorView = view === GerritView.EDIT;
     const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
-    this._showPluginScreen = false;
+    this.showPluginScreen = false;
     // Navigation within plugin screens does not restamp gr-endpoint-decorator
-    // because _showPluginScreen value does not change. To force restamp,
-    // change _showPluginScreen value between true and false.
+    // because showPluginScreen value does not change. To force restamp,
+    // change showPluginScreen value between true and false.
     if (isPluginScreen) {
-      setTimeout(() => (this._showPluginScreen = true), 1);
+      setTimeout(() => (this.showPluginScreen = true), 1);
     }
-    this._showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
+    this.showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
     if (
       this.params &&
       isAppElementJustRegisteredParams(this.params) &&
       this.params.justRegistered
     ) {
       this.loadRegistrationDialog = true;
-      flush();
-      const registrationOverlay = this.shadowRoot!.querySelector(
-        '#registrationOverlay'
-      ) as GrOverlay;
-      const registrationDialog = this.shadowRoot!.querySelector(
-        '#registrationDialog'
-      ) as GrRegistrationDialog;
-      registrationOverlay.open();
-      registrationDialog.loadData().then(() => {
-        registrationOverlay.refit();
+      await this.updateComplete;
+      assertIsDefined(this.registrationOverlay, 'registrationOverlay');
+      assertIsDefined(this.registrationDialog, 'registrationDialog');
+      await this.registrationOverlay.open();
+      await this.registrationDialog.loadData().then(() => {
+        this.registrationOverlay!.refit();
       });
     }
     // To fix bug announce read after each new view, we reset announce with
@@ -394,27 +650,28 @@
     fireIronAnnounce(this, ' ');
   }
 
-  _handlePageError(e: CustomEvent<PageErrorEventDetail>) {
+  private handlePageError(e: CustomEvent<PageErrorEventDetail>) {
     const props = [
-      '_showChangeListView',
-      '_showDashboardView',
-      '_showChangeView',
-      '_showDiffView',
-      '_showSettingsView',
-      '_showAdminView',
+      'showChangeListView',
+      'showDashboardView',
+      'showChangeView',
+      'showDiffView',
+      'showSettingsView',
+      'showAdminView',
     ];
     for (const showProp of props) {
-      this.set(showProp, false);
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      (this as any)[showProp as any] = false;
     }
 
-    this.$.errorView.classList.add('show');
+    this.errorView?.classList.add('show');
     const response = e.detail.response;
     const err: ErrorInfo = {
       text: [response?.status, response?.statusText].join(' '),
     };
     if (response?.status === 404) {
       err.emoji = '¯\\_(ツ)_/¯';
-      this._lastError = err;
+      this.lastError = err;
     } else {
       err.emoji = 'o_O';
       if (response) {
@@ -428,29 +685,29 @@
             errorText: text,
             trace,
           });
-          this._lastError = err;
+          this.lastError = err;
         });
       }
     }
   }
 
-  _handleLocationChange(e: LocationChangeEvent) {
-    this._updateLoginUrl();
+  private handleLocationChange(e: LocationChangeEvent) {
+    this.updateLoginUrl();
 
     const hash = e.detail.hash.substring(1);
     let pathname = e.detail.pathname;
     if (pathname.startsWith('/c/') && Number(hash) > 0) {
       pathname += '@' + hash;
     }
-    this._path = pathname;
+    this.path = pathname;
   }
 
-  _updateLoginUrl() {
+  private updateLoginUrl() {
     const baseUrl = getBaseUrl();
     if (baseUrl) {
       // Strip the canonical path from the path since needing canonical in
       // the path is unneeded and breaks the url.
-      this._loginUrl =
+      this.loginUrl =
         baseUrl +
         '/login/' +
         encodeURIComponent(
@@ -460,7 +717,7 @@
             window.location.hash
         );
     } else {
-      this._loginUrl =
+      this.loginUrl =
         '/login/' +
         encodeURIComponent(
           window.location.pathname +
@@ -470,18 +727,15 @@
     }
   }
 
-  @observe('params.*')
-  _paramsChanged(
-    paramsRecord: ElementPropertyDeepChange<GrAppElement, 'params'>
-  ) {
-    const params = paramsRecord.base;
+  // private but used in test
+  paramsChanged() {
     const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
-    if (params?.view && viewsToCheck.includes(params.view)) {
-      this._lastSearchPage = location.pathname;
+    if (this.params?.view && viewsToCheck.includes(this.params.view)) {
+      this.lastSearchPage = location.pathname;
     }
   }
 
-  _handleTitleChange(e: CustomEvent<TitleChangeEventDetail>) {
+  private handleTitleChange(e: CustomEvent<TitleChangeEventDetail>) {
     if (e.detail.title) {
       document.title = e.detail.title + ' · Gerrit Code Review';
     } else {
@@ -489,98 +743,85 @@
     }
   }
 
-  _handleDialogChange(e: CustomEvent<DialogChangeEventDetail>) {
+  private handleDialogChange(e: CustomEvent<DialogChangeEventDetail>) {
     if (e.detail.canceled) {
-      this._footerHeaderAriaHidden = false;
+      this.footerHeaderAriaHidden = false;
     } else if (e.detail.opened) {
-      this._footerHeaderAriaHidden = true;
+      this.footerHeaderAriaHidden = true;
     }
   }
 
-  handleShowKeyboardShortcuts() {
+  private async showKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
-    flush();
-    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
-  }
+    await this.updateComplete;
+    assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
 
-  _showKeyboardShortcuts() {
-    // same shortcut should close the dialog if pressed again
-    // when dialog is open
-    this.loadKeyboardShortcutsDialog = true;
-    flush();
-    const keyboardShortcuts = this.shadowRoot!.querySelector(
-      '#keyboardShortcuts'
-    ) as GrOverlay;
-    if (!keyboardShortcuts) return;
-    if (keyboardShortcuts.opened) {
-      keyboardShortcuts.cancel();
+    if (this.keyboardShortcuts.opened) {
+      this.keyboardShortcuts.cancel();
       return;
     }
-    keyboardShortcuts.open();
-    this._footerHeaderAriaHidden = true;
-    this._mainAriaHidden = true;
+    this.footerHeaderAriaHidden = true;
+    this.mainAriaHidden = true;
+    await this.keyboardShortcuts.open();
   }
 
-  _handleKeyboardShortcutDialogClose() {
-    (
-      this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay
-    ).cancel();
+  private handleKeyboardShortcutDialogClose() {
+    assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
+    this.keyboardShortcuts.close();
   }
 
   onOverlayCanceled() {
-    this._footerHeaderAriaHidden = false;
-    this._mainAriaHidden = false;
+    this.footerHeaderAriaHidden = false;
+    this.mainAriaHidden = false;
   }
 
-  _handleAccountDetailUpdate() {
-    this.$.mainHeader.reload();
+  private handleAccountDetailUpdate() {
+    this.mainHeader?.reload();
     if (this.params?.view === GerritView.SETTINGS) {
-      (
-        this.shadowRoot!.querySelector('gr-settings-view') as GrSettingsView
-      ).reloadAccountDetail();
+      assertIsDefined(this.settingdView, 'settingdView');
+      this.settingdView.reloadAccountDetail();
     }
   }
 
-  _handleRegistrationDialogClose() {
+  private handleRegistrationDialogClose() {
     // The registration dialog is visible only if this.params is
     // instanceof AppElementJustRegisteredParams
     (this.params as AppElementJustRegisteredParams).justRegistered = false;
-    (
-      this.shadowRoot!.querySelector('#registrationOverlay') as GrOverlay
-    ).close();
+    assertIsDefined(this.registrationOverlay, 'registrationOverlay');
+    this.registrationOverlay.close();
   }
 
-  _goToOpenedChanges() {
+  private goToOpenedChanges() {
     GerritNav.navigateToStatusSearch('open');
   }
 
-  _goToUserDashboard() {
+  private goToUserDashboard() {
     GerritNav.navigateToUserDashboard();
   }
 
-  _goToMergedChanges() {
+  private goToMergedChanges() {
     GerritNav.navigateToStatusSearch('merged');
   }
 
-  _goToAbandonedChanges() {
+  private goToAbandonedChanges() {
     GerritNav.navigateToStatusSearch('abandoned');
   }
 
-  _goToWatchedChanges() {
+  private goToWatchedChanges() {
     // The query is hardcoded, and doesn't respect custom menu entries
     GerritNav.navigateToSearchQuery('is:watched is:open');
   }
 
-  _computePluginScreenName(params: AppElementParams) {
-    if (params.view !== GerritView.PLUGIN_SCREEN) return '';
-    if (!params.plugin || !params.screen) return '';
-    return `${params.plugin}-screen-${params.screen}`;
+  private computePluginScreenName() {
+    if (this.params?.view !== GerritView.PLUGIN_SCREEN) return '';
+    if (!this.params.plugin || !this.params.screen) return '';
+    return `${this.params.plugin}-screen-${this.params.screen}`;
   }
 
-  _logWelcome() {
+  private logWelcome() {
     console.group('Runtime Info');
     console.info('Gerrit UI (PolyGerrit)');
-    console.info(`Gerrit Server Version: ${this._version}`);
+    console.info(`Gerrit Server Version: ${this.version}`);
     if (window.VERSION_INFO) {
       console.info(`UI Version Info: ${window.VERSION_INFO}`);
     }
@@ -592,11 +833,11 @@
    * Note: the REST API interface cannot use gr-reporting directly because
    * that would create a cyclic dependency.
    */
-  _handleRpcLog(e: RpcLogEvent) {
+  private handleRpcLog(e: RpcLogEvent) {
     this.reporting.reportRpcTiming(e.detail.anonymizedUrl, e.detail.elapsed);
   }
 
-  _mobileSearchToggle() {
+  private mobileSearchToggle() {
     this.mobileSearch = !this.mobileSearch;
   }
 
@@ -607,10 +848,20 @@
       : 'app-theme-light';
   }
 
-  _handleViewStateChanged(e: ValueChangedEvent<ChangeListViewState>) {
-    if (!this._viewState) return;
-    this._viewState.changeListView = {
-      ...this._viewState.changeListView,
+  private handleViewStateChanged(e: ValueChangedEvent<ChangeListViewState>) {
+    if (!this.viewState) return;
+    this.viewState.changeListView = {
+      ...this.viewState.changeListView,
+      ...e.detail.value,
+    };
+  }
+
+  private handleViewStateChangeViewChanged(
+    e: ValueChangedEvent<ChangeViewState>
+  ) {
+    if (!this.viewState) return;
+    this.viewState.changeView = {
+      ...this.viewState.changeView,
       ...e.detail.value,
     };
   }
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
deleted file mode 100644
index 5b96720..0000000
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--background-color-tertiary);
-      display: flex;
-      flex-direction: column;
-      min-height: 100%;
-    }
-    gr-main-header,
-    footer {
-      color: var(--primary-text-color);
-    }
-    gr-main-header {
-      background: var(
-        --header-background,
-        var(--header-background-color, #eee)
-      );
-      padding: var(--header-padding);
-      border-bottom: var(--header-border-bottom);
-      border-image: var(--header-border-image);
-      border-right: 0;
-      border-left: 0;
-      border-top: 0;
-      box-shadow: var(--header-box-shadow);
-      /* Make sure the header is above the main content, to preserve box-shadow
-         visibility. We need 2 here instead of 1, because dropdowns in the
-         header should be shown on top of the sticky diff header, which has a
-         z-index of 1. */
-      z-index: 2;
-    }
-    footer {
-      background: var(
-        --footer-background,
-        var(--footer-background-color, #eee)
-      );
-      border-top: var(--footer-border-top);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-      z-index: 100;
-    }
-    main {
-      flex: 1;
-      padding-bottom: var(--spacing-xxl);
-      position: relative;
-    }
-    .errorView {
-      align-items: center;
-      display: none;
-      flex-direction: column;
-      justify-content: center;
-      position: absolute;
-      top: 0;
-      right: 0;
-      bottom: 0;
-      left: 0;
-    }
-    .errorView.show {
-      display: flex;
-    }
-    .errorEmoji {
-      font-size: 2.6rem;
-    }
-    .errorText,
-    .errorMoreInfo {
-      margin-top: var(--spacing-m);
-    }
-    .errorText {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    .errorMoreInfo {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
-  <gr-main-header
-    id="mainHeader"
-    search-query="[[params.query]]"
-    on-mobile-search="_mobileSearchToggle"
-    on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
-    mobile-search-hidden="[[!mobileSearch]]"
-    login-url="[[_loginUrl]]"
-    aria-hidden="[[_footerHeaderAriaHidden]]"
-  >
-  </gr-main-header>
-  <main aria-hidden="[[_mainAriaHidden]]">
-    <template is="dom-if" if="[[mobileSearch]]">
-      <gr-smart-search
-        id="search"
-        label="Search for changes"
-        search-query="[[params.query]]"
-        server-config="[[_serverConfig]]"
-        hidden="[[!mobileSearch]]"
-      >
-      </gr-smart-search>
-    </template>
-    <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
-      <gr-change-list-view
-        params="[[params]]"
-        account="[[_account]]"
-        view-state="[[_viewState.changeListView]]"
-        on-view-state-changed="_handleViewStateChanged"
-      ></gr-change-list-view>
-    </template>
-    <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
-      <gr-dashboard-view
-        account="[[_account]]"
-        params="[[params]]"
-        view-state="{{_viewState.dashboardView}}"
-      ></gr-dashboard-view>
-    </template>
-    <!-- Note that the change view does not have restamp="true" set, because we
-         want to re-use it as long as the change number does not change. -->
-    <template id="dom-if-change-view" is="dom-if" if="[[_showChangeView]]">
-      <gr-change-view
-        params="[[params]]"
-        view-state="{{_viewState.changeView}}"
-        back-page="[[_lastSearchPage]]"
-      ></gr-change-view>
-    </template>
-    <template is="dom-if" if="[[_showEditorView]]" restamp="true">
-      <gr-editor-view params="[[params]]"></gr-editor-view>
-    </template>
-    <!-- Note that the diff view does not have restamp="true" set, because we
-         want to re-use it as long as the change number does not change. -->
-    <template id="dom-if-diff-view" is="dom-if" if="[[_showDiffView]]">
-      <gr-diff-view
-        params="[[params]]"
-        change-view-state="{{_viewState.changeView}}"
-      ></gr-diff-view>
-    </template>
-    <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-      <gr-settings-view
-        params="[[params]]"
-        on-account-detail-update="_handleAccountDetailUpdate"
-      >
-      </gr-settings-view>
-    </template>
-    <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-      <gr-admin-view path="[[_path]]" params="[[params]]"></gr-admin-view>
-    </template>
-    <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
-      <gr-endpoint-decorator name="[[_pluginScreenName]]">
-        <gr-endpoint-param
-          name="token"
-          value="[[params.screen]]"
-        ></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </template>
-    <template is="dom-if" if="[[_showCLAView]]" restamp="true">
-      <gr-cla-view></gr-cla-view>
-    </template>
-    <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
-      <gr-documentation-search params="[[params]]"> </gr-documentation-search>
-    </template>
-    <div id="errorView" class="errorView">
-      <div class="errorEmoji">[[_lastError.emoji]]</div>
-      <div class="errorText">[[_lastError.text]]</div>
-      <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
-    </div>
-  </main>
-  <footer r="contentinfo" aria-hidden="[[_footerHeaderAriaHidden]]">
-    <div>
-      Powered by
-      <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank"
-        >Gerrit Code Review</a
-      >
-      ([[_version]])
-      <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
-    </div>
-    <div>
-      Press “?” for keyboard shortcuts
-      <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
-    </div>
-  </footer>
-  <template is="dom-if" if="[[loadKeyboardShortcutsDialog]]">
-    <gr-overlay
-      id="keyboardShortcuts"
-      with-backdrop=""
-      on-iron-overlay-canceled="onOverlayCanceled"
-    >
-      <gr-keyboard-shortcuts-dialog
-        on-close="_handleKeyboardShortcutDialogClose"
-      ></gr-keyboard-shortcuts-dialog>
-    </gr-overlay>
-  </template>
-  <template is="dom-if" if="[[loadRegistrationDialog]]">
-    <gr-overlay id="registrationOverlay" with-backdrop="">
-      <gr-registration-dialog
-        id="registrationDialog"
-        settings-url="[[_settingsUrl]]"
-        on-account-detail-update="_handleAccountDetailUpdate"
-        on-close="_handleRegistrationDialogClose"
-      >
-      </gr-registration-dialog>
-    </gr-overlay>
-  </template>
-  <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-  <gr-error-manager
-    id="errorManager"
-    login-url="[[_loginUrl]]"
-  ></gr-error-manager>
-  <gr-router id="router"></gr-router>
-  <gr-plugin-host id="plugins" config="[[_serverConfig]]"> </gr-plugin-host>
-  <gr-external-style
-    id="externalStyleForAll"
-    name="app-theme"
-  ></gr-external-style>
-  <gr-external-style
-    id="externalStyleForTheme"
-    name="[[getThemeEndpoint()]]"
-  ></gr-external-style>
-`;
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 02ae6c6..251f3ea 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -29,6 +29,7 @@
 } from '../test/test-data-generators';
 import {GrAppElement} from './gr-app-element';
 import {GrPluginHost} from './plugins/gr-plugin-host/gr-plugin-host';
+import {GrRouter} from './core/gr-router/gr-router';
 
 suite('gr-app tests', () => {
   let grApp: GrApp;
@@ -39,7 +40,7 @@
   setup(async () => {
     appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
     stub('gr-account-dropdown', '_getTopContent');
-    routerStartStub = stub('gr-router', 'start');
+    routerStartStub = sinon.stub(GrRouter.prototype, 'start');
     stubRestApi('getAccount').returns(Promise.resolve(undefined));
     stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
     stubRestApi('getConfig').returns(Promise.resolve(config));
@@ -48,7 +49,7 @@
     stubRestApi('probePath').returns(Promise.resolve(false));
 
     grApp = await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
-    await flush();
+    await grApp.updateComplete;
   });
 
   test('reporting', () => {
@@ -67,21 +68,13 @@
 
   test('_paramsChanged sets search page', () => {
     const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
-    const paramsForChangeView = createAppElementChangeViewParams();
-    const paramsForSearchView = createAppElementSearchViewParams();
 
-    grAppElement._paramsChanged({
-      base: paramsForChangeView,
-      value: paramsForChangeView,
-      path: '',
-    });
-    assert.notOk(grAppElement._lastSearchPage);
+    grAppElement.params = createAppElementChangeViewParams();
+    grAppElement.paramsChanged();
+    assert.notOk(grAppElement.lastSearchPage);
 
-    grAppElement._paramsChanged({
-      base: paramsForSearchView,
-      value: paramsForSearchView,
-      path: '',
-    });
-    assert.ok(grAppElement._lastSearchPage);
+    grAppElement.params = createAppElementSearchViewParams();
+    grAppElement.paramsChanged();
+    assert.ok(grAppElement.lastSearchPage);
   });
 });
diff --git a/polygerrit-ui/app/elements/gr-css-mixins.ts b/polygerrit-ui/app/elements/gr-css-mixins.ts
new file mode 100644
index 0000000..7a57a17
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-css-mixins.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement} from '@polymer/decorators';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+@customElement('gr-css-mixins')
+export class GrCssMixins extends PolymerElement {
+  /* eslint-disable lit/prefer-static-styles */
+  static get template() {
+    return html`
+      <style>
+        :host {
+          /* If you want to use css-mixins in Lit elements, then you have to first
+          use them in a PolymerElement somewhere. We are collecting all css-
+          mixin usage here, but we may move them somewhere else later when
+          converting gr-app-element to Lit. In the Lit element you can then use
+          the css variables directly such as --paper-input-container_-_padding,
+          so you don't have to mess with mixins at all.
+          */
+          --paper-input-container: {
+            padding: 8px 0;
+          }
+          --paper-input-container-input: {
+            font-size: var(--font-size-normal);
+            line-height: var(--line-height-normal);
+            color: var(--primary-text-color);
+          }
+          --paper-input-container-underline: {
+            height: 0;
+            display: none;
+          }
+          --paper-input-container-underline-focus: {
+            height: 0;
+            display: none;
+          }
+          --paper-input-container-underline-disabled: {
+            height: 0;
+            display: none;
+          }
+          --paper-input-container-label: {
+            display: none;
+          }
+        }
+      </style>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-css-mixins': GrCssMixins;
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/subscription-controller.ts b/polygerrit-ui/app/elements/lit/subscription-controller.ts
index ab4ed64..fdd24cf 100644
--- a/polygerrit-ui/app/elements/lit/subscription-controller.ts
+++ b/polygerrit-ui/app/elements/lit/subscription-controller.ts
@@ -1,45 +1,50 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ReactiveController, ReactiveControllerHost} from 'lit';
 import {Observable, Subscription} from 'rxjs';
+import {Provider} from '../../models/dependency';
+
+export class SubscriptionError extends Error {
+  constructor(message: string) {
+    super(message);
+  }
+}
 
 /**
  * Enables components to simply hook up a property with an Observable like so:
  *
- * subscribe(this, obs$, x => (this.prop = x));
+ * subscribe(this, () => obs$, x => (this.prop = x));
  */
 export function subscribe<T>(
-  host: ReactiveControllerHost,
-  obs$: Observable<T>,
-  setProp: (t: T) => void
+  host: ReactiveControllerHost & HTMLElement,
+  provider: Provider<Observable<T>>,
+  callback: (t: T) => void
 ) {
-  host.addController(new SubscriptionController(obs$, setProp));
+  if (host.isConnected)
+    throw new Error(
+      'Subscriptions should happen before a component is connected'
+    );
+  const controller = new SubscriptionController(provider, callback);
+  host.addController(controller);
 }
 
 export class SubscriptionController<T> implements ReactiveController {
   private sub?: Subscription;
 
   constructor(
-    private readonly obs$: Observable<T>,
-    private readonly setProp: (t: T) => void
+    private readonly provider: Provider<Observable<T>>,
+    private readonly callback: (t: T) => void
   ) {}
 
   hostConnected() {
-    this.sub = this.obs$.subscribe(this.setProp);
+    this.sub = this.provider().subscribe(v => this.update(v));
+  }
+
+  update(value: T) {
+    this.callback(value);
   }
 
   hostDisconnected() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
similarity index 70%
rename from polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
rename to polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
index 6fd2505..b953885 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
@@ -15,25 +15,28 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {AdminPluginApi} from '../../../api/admin';
+import {PluginApi} from '../../../api/plugin';
+import '../../../test/common-test-setup-karma';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-admin-api tests', () => {
-  let adminApi;
+  let adminApi: AdminPluginApi;
+  let plugin: PluginApi;
 
   setup(() => {
-    let plugin;
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
     getPluginLoader().loadPlugins([]);
     adminApi = plugin.admin();
   });
 
-  teardown(() => {
-    adminApi = null;
-  });
-
   test('exists', () => {
     assert.isOk(adminApi);
   });
@@ -49,8 +52,10 @@
     adminApi.addMenuLink('text', 'url', 'capability');
     const links = adminApi.getMenuLinks();
     assert.equal(links.length, 1);
-    assert.deepEqual(links[0],
-        {text: 'text', url: 'url', capability: 'capability'});
+    assert.deepEqual(links[0], {
+      text: 'text',
+      url: 'url',
+      capability: 'capability',
+    });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index 6b8c4f0..1ca9918 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
@@ -81,20 +69,17 @@
   }
 
   _createPlaceholder(hookName: string) {
-    class HookPlaceholder extends PolymerElement {
-      static get is() {
-        return hookName;
-      }
+    /**
+     * See gr-endpoint-decorator.ts for how hooks are instantiated and
+     * initialized.
+     */
+    class HookPlaceholder extends HTMLElement {
+      plugin?: PluginApi;
 
-      static get properties() {
-        return {
-          plugin: Object,
-          content: Object,
-        };
-      }
+      content?: Element | null;
     }
 
-    customElements.define(HookPlaceholder.is, HookPlaceholder);
+    customElements.define(hookName, HookPlaceholder);
   }
 
   handleInstanceDetached(instance: T) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
deleted file mode 100644
index 025f2b4..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
-
-suite('gr-dom-hooks tests', () => {
-  let instance;
-  let hook;
-
-  setup(() => {
-    let plugin;
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrDomHooksManager(plugin);
-  });
-
-  suite('placeholder', () => {
-    setup(()=>{
-      sinon.stub(GrDomHook.prototype, '_createPlaceholder');
-      hook = instance.getDomHook('foo-bar');
-    });
-
-    test('registers placeholder class', () => {
-      assert.isTrue(hook._createPlaceholder.calledWithExactly(
-          'testplugin-autogenerated-foo-bar'));
-    });
-
-    test('getModuleName()', () => {
-      const hookName = Object.keys(instance.hooks).pop();
-      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
-      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
-    });
-  });
-
-  suite('custom element', () => {
-    setup(() => {
-      hook = instance.getDomHook('foo-bar', 'my-el');
-    });
-
-    test('getModuleName()', () => {
-      const hookName = Object.keys(instance.hooks).pop();
-      assert.equal(hookName, 'foo-bar my-el');
-      assert.equal(hook.getModuleName(), 'my-el');
-    });
-
-    test('onAttached', () => {
-      const onAttachedSpy = sinon.spy();
-      hook.onAttached(onAttachedSpy);
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      hook.handleInstanceAttached(el1);
-      hook.handleInstanceAttached(el2);
-      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
-      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
-    });
-
-    test('onDetached', () => {
-      const onDetachedSpy = sinon.spy();
-      hook.onDetached(onDetachedSpy);
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      hook.handleInstanceDetached(el1);
-      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
-      hook.handleInstanceDetached(el2);
-      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
-    });
-
-    test('getAllAttached', () => {
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      el1.textContent = 'one';
-      el2.textContent = 'two';
-      hook.handleInstanceAttached(el1);
-      hook.handleInstanceAttached(el2);
-      assert.deepEqual([el1, el2], hook.getAllAttached());
-      hook.handleInstanceDetached(el1);
-      assert.deepEqual([el2], hook.getAllAttached());
-    });
-
-    test('getLastAttached', () => {
-      const beforeAttachedPromise = hook.getLastAttached().then(
-          el => assert.strictEqual(el1, el));
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      el1.textContent = 'one';
-      el2.textContent = 'two';
-      hook.handleInstanceAttached(el1);
-      hook.handleInstanceAttached(el2);
-      const afterAttachedPromise = hook.getLastAttached().then(
-          el => assert.strictEqual(el2, el));
-      return Promise.all([
-        beforeAttachedPromise,
-        afterAttachedPromise,
-      ]);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
new file mode 100644
index 0000000..6001622
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {HookApi, PluginElement} from '../../../api/hook';
+import {PluginApi} from '../../../api/plugin';
+import '../../../test/common-test-setup-karma';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks';
+
+suite('gr-dom-hooks tests', () => {
+  let instance: GrDomHooksManager;
+  let hook: HookApi<PluginElement>;
+  let plugin: PluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    instance = new GrDomHooksManager(plugin);
+  });
+
+  suite('placeholder', () => {
+    let createPlaceHolderStub: sinon.SinonStub;
+
+    setup(() => {
+      createPlaceHolderStub = sinon.stub(
+        GrDomHook.prototype,
+        '_createPlaceholder'
+      );
+      hook = instance.getDomHook('foo-bar');
+    });
+
+    test('registers placeholder class', () => {
+      assert.isTrue(
+        createPlaceHolderStub.calledWithExactly(
+          'testplugin-autogenerated-foo-bar'
+        )
+      );
+    });
+
+    test('getModuleName()', () => {
+      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
+    });
+  });
+
+  suite('custom element', () => {
+    setup(() => {
+      hook = instance.getDomHook('foo-bar', 'my-el');
+    });
+
+    test('getModuleName()', () => {
+      assert.equal(hook.getModuleName(), 'my-el');
+    });
+
+    test('onAttached', () => {
+      const onAttachedSpy = sinon.spy();
+      hook.onAttached(onAttachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('onDetached', () => {
+      const onDetachedSpy = sinon.spy();
+      hook.onDetached(onDetachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hook.handleInstanceDetached(el1);
+      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
+      hook.handleInstanceDetached(el2);
+      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('getAllAttached', () => {
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      assert.deepEqual([el1, el2], hook.getAllAttached());
+      hook.handleInstanceDetached(el1);
+      assert.deepEqual([el2], hook.getAllAttached());
+    });
+
+    test('getLastAttached', () => {
+      const beforeAttachedPromise = hook
+        .getLastAttached()
+        .then((el: HTMLElement) => assert.strictEqual(el1, el));
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      const afterAttachedPromise = hook
+        .getLastAttached()
+        .then((el: HTMLElement) => assert.strictEqual(el2, el));
+      return Promise.all([beforeAttachedPromise, afterAttachedPromise]);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index cb8bffb..eb59134 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -1,39 +1,24 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-endpoint-decorator_html';
+import {html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {
   getPluginEndpoints,
   ModuleInfo,
 } from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, PluginElement} from '../../../api/hook';
 import {getAppContext} from '../../../services/app-context';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
 @customElement('gr-endpoint-decorator')
-export class GrEndpointDecorator extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEndpointDecorator extends LitElement {
   /**
    * If set, then this endpoint only invokes callbacks registered by the target
    * plugin. For example this is used for the `check-result-expanded` endpoint.
@@ -43,39 +28,51 @@
   @property({type: String})
   targetPlugin?: string;
 
+  /** Required. */
   @property({type: String})
-  name!: string;
+  name?: string;
 
-  @property({type: Object})
-  _domHooks = new Map<PluginElement, HookApi<PluginElement>>();
+  private readonly domHooks = new Map<PluginElement, HookApi<PluginElement>>();
 
-  @property({type: Object})
-  _initializedPlugins = new Map<string, boolean>();
-
-  /**
-   * This is the callback that the plugin endpoint manager should be calling
-   * when a new element is registered for this endpoint. It points to
-   * _initModule().
-   */
-  _endpointCallBack: (info: ModuleInfo) => void = () => {};
+  private readonly initializedPlugins = new Map<string, boolean>();
 
   private readonly reporting = getAppContext().reportingService;
 
+  override render() {
+    return html`<slot></slot>`;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    assertIsDefined(this.name);
+    getPluginEndpoints().onNewEndpoint(this.name, this.initModule);
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        assertIsDefined(this.name);
+        const modules = getPluginEndpoints().getDetails(this.name);
+        for (const module of modules) {
+          this.initModule(module);
+        }
+      });
+  }
+
   override disconnectedCallback() {
-    for (const [el, domHook] of this._domHooks) {
+    for (const [el, domHook] of this.domHooks) {
       domHook.handleInstanceDetached(el);
     }
-    getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+    assertIsDefined(this.name);
+    getPluginEndpoints().onDetachedEndpoint(this.name, this.initModule);
     super.disconnectedCallback();
   }
 
-  _initDecoration(
+  private initDecoration(
     name: string,
     plugin: PluginApi,
     slot?: string
   ): Promise<HTMLElement> {
     const el = document.createElement(name) as PluginElement;
-    return this._initProperties(
+    return this.initProperties(
       el,
       plugin,
       // The direct children are slotted into <slot>, so this is identical to
@@ -88,13 +85,16 @@
       if (slot && slotEl?.parentNode) {
         slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
       } else {
-        this._appendChild(el);
+        this.appendChild(el);
       }
       return el;
     });
   }
 
-  _initReplacement(name: string, plugin: PluginApi): Promise<HTMLElement> {
+  private initReplacement(
+    name: string,
+    plugin: PluginApi
+  ): Promise<HTMLElement> {
     // The direct children are slotted into <slot>, so they are identical to
     // this.shadowRoot.querySelector('slot').assignedElements().
     const directChildren = [...this.childNodes];
@@ -102,18 +102,18 @@
     [...directChildren, ...shadowChildren]
       .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
       .filter(node => node.nodeName !== 'SLOT')
-      .forEach(node => (node as ChildNode).remove());
+      .forEach(node => node.remove());
     const el = document.createElement(name);
-    return this._initProperties(el, plugin).then((el: HTMLElement) =>
-      this._appendChild(el)
+    return this.initProperties(el, plugin).then((el: HTMLElement) =>
+      this.appendChild(el)
     );
   }
 
-  _getEndpointParams() {
+  private getEndpointParams() {
     return Array.from(this.querySelectorAll('gr-endpoint-param'));
   }
 
-  _initProperties(
+  private initProperties(
     el: PluginElement,
     plugin: PluginApi,
     content?: Element | null
@@ -128,10 +128,9 @@
     if (content) {
       el.content = content as HTMLElement;
     }
-    const expectProperties = this._getEndpointParams().map(paramEl => {
+    const expectProperties = this.getEndpointParams().map(paramEl => {
       const helper = plugin.attributeHelper(paramEl);
-      // TODO: this should be replaced by accessing the property directly
-      const paramName = paramEl.getAttribute('name');
+      const paramName = paramEl.name;
       if (!paramName) {
         this.reporting.error(
           new Error(
@@ -170,53 +169,40 @@
       });
   }
 
-  _appendChild(el: HTMLElement): HTMLElement {
-    if (!this.root) throw Error('plugin endpoint decorator missing root');
-    return this.root.appendChild(el);
-  }
-
-  _initModule({moduleName, plugin, type, domHook, slot}: ModuleInfo) {
+  private readonly initModule = ({
+    moduleName,
+    plugin,
+    type,
+    domHook,
+    slot,
+  }: ModuleInfo) => {
     const name = plugin.getPluginName() + '.' + moduleName;
     if (this.targetPlugin) {
       if (this.targetPlugin !== plugin.getPluginName()) return;
     }
-    if (this._initializedPlugins.get(name)) {
+    if (this.initializedPlugins.get(name)) {
       return;
     }
     let initPromise;
     switch (type) {
       case 'decorate':
-        initPromise = this._initDecoration(moduleName, plugin, slot);
+        initPromise = this.initDecoration(moduleName, plugin, slot);
         break;
       case 'replace':
-        initPromise = this._initReplacement(moduleName, plugin);
+        initPromise = this.initReplacement(moduleName, plugin);
         break;
     }
     if (!initPromise) {
       throw Error(`unknown endpoint type ${type} used by plugin ${name}`);
     }
-    this._initializedPlugins.set(name, true);
+    this.initializedPlugins.set(name, true);
     initPromise.then(el => {
       if (domHook) {
         domHook.handleInstanceAttached(el);
-        this._domHooks.set(el, domHook);
+        this.domHooks.set(el, domHook);
       }
     });
-  }
-
-  override ready() {
-    super.ready();
-    if (!this.name) return;
-    this._endpointCallBack = (info: ModuleInfo) => this._initModule(info);
-    getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() =>
-        getPluginEndpoints()
-          .getDetails(this.name)
-          .forEach(this._initModule, this)
-      );
-  }
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
index f5096ea..d7acc61 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -1,60 +1,75 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-endpoint-decorator';
 import '../gr-endpoint-param/gr-endpoint-param';
 import '../gr-endpoint-slot/gr-endpoint-slot';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {resetPlugins} from '../../../test/test-utils';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {
+  mockPromise,
+  queryAndAssert,
+  resetPlugins,
+} from '../../../test/test-utils';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {GrEndpointDecorator} from './gr-endpoint-decorator';
 import {PluginApi} from '../../../api/plugin';
 import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param';
 
-const basicFixture = fixtureFromTemplate(
-  html`<div>
-    <gr-endpoint-decorator name="first">
-      <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
-      <p>
-        <span>test slot</span>
-        <gr-endpoint-slot name="test"></gr-endpoint-slot>
-      </p>
-    </gr-endpoint-decorator>
-    <gr-endpoint-decorator name="second">
-      <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
-    </gr-endpoint-decorator>
-    <gr-endpoint-decorator name="banana">
-      <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
-    </gr-endpoint-decorator>
-  </div>`
-);
-
 suite('gr-endpoint-decorator', () => {
-  let container: GrEndpointDecorator;
+  let container: HTMLElement;
 
   let plugin: PluginApi;
   let decorationHook: any;
   let decorationHookWithSlot: any;
   let replacementHook: any;
+  let first: GrEndpointDecorator;
+  let second: GrEndpointDecorator;
+  let banana: GrEndpointDecorator;
 
   setup(async () => {
     resetPlugins();
-    container = basicFixture.instantiate() as GrEndpointDecorator;
+    container = await fixture(
+      html`<div>
+        <gr-endpoint-decorator name="first">
+          <gr-endpoint-param
+            name="first-param"
+            .value=${'barbar'}
+          ></gr-endpoint-param>
+          <p>
+            <span>test slot</span>
+            <gr-endpoint-slot name="test"></gr-endpoint-slot>
+          </p>
+        </gr-endpoint-decorator>
+        <gr-endpoint-decorator name="second">
+          <gr-endpoint-param
+            name="second-param"
+            .value=${'foofoo'}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <gr-endpoint-decorator name="banana">
+          <gr-endpoint-param
+            name="banana-param"
+            .value=${'yes'}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>`
+    );
+    first = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="first"]'
+    );
+    second = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="second"]'
+    );
+    banana = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="banana"]'
+    );
+
     window.Gerrit.install(
       p => {
         plugin = p;
@@ -64,18 +79,33 @@
     );
     // Decoration
     decorationHook = plugin.registerCustomComponent('first', 'some-module');
+    const decorationHookPromise = mockPromise();
+    decorationHook.onAttached(() => decorationHookPromise.resolve());
+
+    // Decoration with slot
     decorationHookWithSlot = plugin.registerCustomComponent(
       'first',
       'some-module-2',
       {slot: 'test'}
     );
+    const decorationHookSlotPromise = mockPromise();
+    decorationHookWithSlot.onAttached(() =>
+      decorationHookSlotPromise.resolve()
+    );
+
     // Replacement
     replacementHook = plugin.registerCustomComponent('second', 'other-module', {
       replace: true,
     });
+    const replacementHookPromise = mockPromise();
+    replacementHook.onAttached(() => replacementHookPromise.resolve());
+
     // Mimic all plugins loaded.
     getPluginLoader().loadPlugins([]);
-    await flush();
+
+    await decorationHookPromise;
+    await decorationHookSlotPromise;
+    await replacementHookPromise;
   });
 
   teardown(() => {
@@ -89,17 +119,15 @@
     assert.equal(endpoints.length, 3);
   });
 
-  test('decoration', () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="first"]'
-    ) as GrEndpointDecorator;
-    const modules = Array.from(element.root!.children).filter(
+  test('first decoration', () => {
+    const element = first;
+    const modules = Array.from(element.children).filter(
       element => element.nodeName === 'SOME-MODULE'
     );
     assert.equal(modules.length, 1);
     const [module] = modules;
     assert.isOk(module);
-    assert.equal((module as any)['someparam'], 'barbar');
+    assert.equal((module as any)['first-param'], 'barbar');
     return decorationHook
       .getLastAttached()
       .then((element: any) => {
@@ -112,14 +140,12 @@
   });
 
   test('decoration with slot', () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="first"]'
-    ) as GrEndpointDecorator;
+    const element = first;
     const modules = [...element.querySelectorAll('some-module-2')];
     assert.equal(modules.length, 1);
     const [module] = modules;
     assert.isOk(module);
-    assert.equal((module as any)['someparam'], 'barbar');
+    assert.equal((module as any)['first-param'], 'barbar');
     return decorationHookWithSlot
       .getLastAttached()
       .then((element: any) => {
@@ -132,14 +158,12 @@
   });
 
   test('replacement', () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="second"]'
-    ) as GrEndpointDecorator;
-    const module = Array.from(element.root!.children).find(
+    const element = second;
+    const module = Array.from(element.children).find(
       element => element.nodeName === 'OTHER-MODULE'
     );
     assert.isOk(module);
-    assert.equal((module as any)['someparam'], 'foofoo');
+    assert.equal((module as any)['second-param'], 'foofoo');
     return replacementHook
       .getLastAttached()
       .then((element: any) => {
@@ -152,73 +176,92 @@
   });
 
   test('late registration', async () => {
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const module = Array.from(element.root!.children).find(
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+    await bananaHookPromise;
+
+    const element = banana;
+    const module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
     assert.isOk(module);
   });
 
   test('two modules', async () => {
-    plugin.registerCustomComponent('banana', 'mod-one');
-    plugin.registerCustomComponent('banana', 'mod-two');
-    await flush();
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const module1 = Array.from(element.root!.children).find(
+    const bananaHook1 = plugin.registerCustomComponent('banana', 'mod-one');
+    const bananaHookPromise1 = mockPromise();
+    bananaHook1.onAttached(() => bananaHookPromise1.resolve());
+    await bananaHookPromise1;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'mod-two');
+    const bananaHookPromise2 = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise2.resolve());
+    await bananaHookPromise2;
+
+    const element = banana;
+    const module1 = Array.from(element.children).find(
       element => element.nodeName === 'MOD-ONE'
     );
     assert.isOk(module1);
-    const module2 = Array.from(element.root!.children).find(
+    const module2 = Array.from(element.children).find(
       element => element.nodeName === 'MOD-TWO'
     );
     assert.isOk(module2);
   });
 
   test('late param setup', async () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const param = element.querySelector('gr-endpoint-param') as GrEndpointParam;
+    let element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
     param['value'] = undefined;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    let module = Array.from(element.root!.children).find(
+    await param.updateComplete;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+
+    element = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="banana"]'
+    );
+    let module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
     // Module waits for param to be defined.
     assert.isNotOk(module);
     const value = {abc: 'def'};
     param.value = value;
+    await param.updateComplete;
+    await bananaHookPromise;
 
-    await flush();
-    module = Array.from(element.root!.children).find(
+    module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
     assert.isOk(module);
-    assert.strictEqual((module as any)['someParam'], value);
+    assert.strictEqual((module as any)['banana-param'], value);
   });
 
   test('param is bound', async () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const param = element.querySelector('gr-endpoint-param') as GrEndpointParam;
+    const element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
     const value1 = {abc: 'def'};
     const value2 = {def: 'abc'};
     param.value = value1;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    const module = Array.from(element.root!.children).find(
+    await param.updateComplete;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+    await bananaHookPromise;
+
+    const module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
-    assert.strictEqual((module as any)['someParam'], value1);
+    assert.isOk(module);
+    assert.strictEqual((module as any)['banana-param'], value1);
+
     param.value = value2;
-    assert.strictEqual((module as any)['someParam'], value2);
+    await param.updateComplete;
+    assert.strictEqual((module as any)['banana-param'], value2);
   });
 });
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index ee89c86..5a00c37 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -24,26 +13,18 @@
 }
 
 @customElement('gr-endpoint-param')
-export class GrEndpointParam extends PolymerElement {
-  @property({type: String, reflectToAttribute: true})
+export class GrEndpointParam extends LitElement {
+  @property({type: String, reflect: true})
   name = '';
 
-  @property({
-    type: Object,
-    notify: true,
-    observer: '_valueChanged',
-  })
+  @property({type: Object})
   value?: unknown;
 
-  _valueChanged(value: unknown) {
-    /* In polymer 2 the following change was made:
-    "Property change notifications (property-changed events) aren't fired when
-    the value changes as a result of a binding from the host"
-    (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
-    To workaround this problem, we fire the event from the observer.
-    In some cases this fire the event twice, but our code is
-    ready for it.
-    */
-    this.dispatchEvent(new CustomEvent('value-changed', {detail: {value}}));
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('value')) {
+      this.dispatchEvent(
+        new CustomEvent('value-changed', {detail: {value: this.value}})
+      );
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
index f15b046..d6d1866 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -28,7 +17,7 @@
  * the registered element should appear inside of the endpoint.
  */
 @customElement('gr-endpoint-slot')
-export class GrEndpointSlot extends PolymerElement {
+export class GrEndpointSlot extends LitElement {
   @property({type: String})
   name!: string;
 }
@@ -40,6 +29,6 @@
  * This should help catch errors when you assign an element without
  * name to GrEndpointSlot type.
  */
-export interface GrEndpointSlot extends PolymerElement {
+export interface GrEndpointSlot extends LitElement {
   name: string;
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
deleted file mode 100644
index f555103..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-plugin-host.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-const basicFixture = fixtureFromElement('gr-plugin-host');
-
-suite('gr-plugin-host tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    sinon.stub(document.body, 'appendChild');
-  });
-
-  test('load plugins should be called', async () => {
-    sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
-      plugin: {
-        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
-      },
-    };
-    await flush();
-    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
-    assert.isTrue(getPluginLoader().loadPlugins.calledWith([
-      'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-    ]));
-  });
-
-  test('theme plugins should be loaded if enabled', async () => {
-    sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
-      default_theme: 'gerrit-theme.js',
-      plugin: {
-        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
-      },
-    };
-    await flush();
-    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
-    assert.isTrue(getPluginLoader().loadPlugins.calledWith([
-      'gerrit-theme.js', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-    ]));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
new file mode 100644
index 0000000..701707e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-plugin-host';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {GrPluginHost} from './gr-plugin-host';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {ServerInfo} from '../../../api/rest-api';
+
+suite('gr-plugin-host tests', () => {
+  let element: GrPluginHost;
+
+  setup(async () => {
+    element = await fixture<GrPluginHost>(html`
+      <gr-plugin-host></gr-plugin-host>
+    `);
+
+    sinon.stub(document.body, 'appendChild');
+  });
+
+  test('load plugins should be called', async () => {
+    const loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
+    element.config = {
+      plugin: {
+        has_avatars: false,
+        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
+      },
+    } as ServerInfo;
+    await flush();
+    assert.isTrue(loadPluginsStub.calledOnce);
+    assert.isTrue(
+      loadPluginsStub.calledWith([
+        'plugins/42',
+        'plugins/foo/bar',
+        'plugins/baz',
+      ])
+    );
+  });
+
+  test('theme plugins should be loaded if enabled', async () => {
+    const loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
+    element.config = {
+      default_theme: 'gerrit-theme.js',
+      plugin: {
+        has_avatars: false,
+        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
+      },
+    } as ServerInfo;
+    await flush();
+    assert.isTrue(loadPluginsStub.calledOnce);
+    assert.isTrue(
+      loadPluginsStub.calledWith([
+        'gerrit-theme.js',
+        'plugins/42',
+        'plugins/foo/bar',
+        'plugins/baz',
+      ])
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
index af6a159..e6413b6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -15,10 +15,10 @@
  * limitations under the License.
  */
 import '../../shared/gr-overlay/gr-overlay';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-plugin-popup_html';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {customElement} from '@polymer/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html} from 'lit';
+import {customElement, query} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -26,26 +26,29 @@
   }
 }
 
-export interface GrPluginPopup {
-  $: {
-    overlay: GrOverlay;
-  };
-}
 @customElement('gr-plugin-popup')
-export class GrPluginPopup extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrPluginPopup extends LitElement {
+  @query('#overlay') protected overlay!: GrOverlay;
+
+  static override get styles() {
+    return [sharedStyles];
+  }
+
+  override render() {
+    return html`<gr-overlay id="overlay" with-backdrop="">
+      <slot></slot>
+    </gr-overlay>`;
   }
 
   get opened() {
-    return this.$.overlay.opened;
+    return this.overlay.opened;
   }
 
   open() {
-    return this.$.overlay.open();
+    return this.overlay.open();
   }
 
   close() {
-    this.$.overlay.close();
+    this.overlay.close();
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
deleted file mode 100644
index aa7a92d..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-overlay id="overlay" with-backdrop="">
-    <slot></slot>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
index 342cf83..032bfac 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
@@ -26,8 +26,9 @@
   let overlayOpen: sinon.SinonStub;
   let overlayClose: sinon.SinonStub;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
     overlayOpen = stub('gr-overlay', 'open').callsFake(() => Promise.resolve());
     overlayClose = stub('gr-overlay', 'close');
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index 53f7095..9dbb231 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-plugin-popup';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GrPluginPopup} from './gr-plugin-popup';
 import {PluginApi} from '../../../api/plugin';
 import {PopupPluginApi} from '../../../api/popup';
@@ -45,10 +33,10 @@
     this.reporting.trackApi(this.plugin, 'popup', 'constructor');
   }
 
+  // TODO: This method should be removed as soon as plugins stop
+  // depending on it.
   _getElement() {
-    // TODO(TS): maybe consider removing this if no one is using
-    // anything other than native methods on the return
-    return dom(this.popup) as unknown as HTMLElement;
+    return this.popup;
   }
 
   appendContent(el: HTMLElement) {
@@ -61,13 +49,13 @@
    * Creates the popup if not previously created. Creates popup content element,
    * if it was provided with constructor.
    */
-  open(): Promise<PopupPluginApi> {
+  open(): Promise<GrPopupInterface> {
     this.reporting.trackApi(this.plugin, 'popup', 'open');
     if (!this.openingPromise) {
       this.openingPromise = this.plugin
         .hook('plugin-overlay')
         .getLastAttached()
-        .then(hookEl => {
+        .then(async hookEl => {
           const popup = document.createElement('gr-plugin-popup');
           if (this.moduleName) {
             const el = popup.appendChild(
@@ -76,8 +64,9 @@
             el.plugin = this.plugin;
           }
           this.popup = hookEl.appendChild(popup);
-          flush();
-          return this.popup.open().then(() => this);
+          await this.popup.updateComplete;
+          await this.popup.open();
+          return this;
         });
     }
     return this.openingPromise;
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
deleted file mode 100644
index beedfab..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {GrPopupInterface} from './gr-popup-interface.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-class GrUserTestPopupElement extends PolymerElement {
-  static get is() { return 'gr-user-test-popup'; }
-
-  static get template() {
-    return html`<div id="barfoo">some test module</div>`;
-  }
-}
-
-customElements.define(GrUserTestPopupElement.is, GrUserTestPopupElement);
-
-const containerFixture = fixtureFromElement('div');
-
-suite('gr-popup-interface tests', () => {
-  let container;
-  let instance;
-  let plugin;
-
-  setup(() => {
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    container = containerFixture.instantiate();
-    sinon.stub(plugin, 'hook').returns({
-      getLastAttached() {
-        return Promise.resolve(container);
-      },
-    });
-  });
-
-  suite('manual', () => {
-    setup(() => {
-      instance = new GrPopupInterface(plugin);
-    });
-
-    test('open', async () => {
-      const api = await instance.open();
-      assert.strictEqual(api, instance);
-      const manual = document.createElement('div');
-      manual.id = 'foobar';
-      manual.innerHTML = 'manual content';
-      api._getElement().appendChild(manual);
-      await flush();
-      assert.equal(
-          container.querySelector('#foobar').textContent, 'manual content');
-    });
-
-    test('close', async () => {
-      const api = await instance.open();
-      assert.isTrue(api._getElement().node.opened);
-      api.close();
-      assert.isFalse(api._getElement().node.opened);
-    });
-  });
-
-  suite('components', () => {
-    setup(() => {
-      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
-    });
-
-    test('open', async () => {
-      await instance.open();
-      assert.isNotNull(container.querySelector('gr-user-test-popup'));
-    });
-
-    test('close', async () => {
-      const api = await instance.open();
-      assert.isTrue(api._getElement().node.opened);
-      api.close();
-      assert.isFalse(api._getElement().node.opened);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts
new file mode 100644
index 0000000..ea95f0c
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GrPopupInterface} from './gr-popup-interface';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi, PluginElement} from '../../../api/hook';
+import {queryAndAssert} from '../../../test/test-utils';
+import {LitElement, html} from 'lit';
+import {customElement} from 'lit/decorators';
+
+@customElement('gr-user-test-popup')
+class GrUserTestPopupElement extends LitElement {
+  override render() {
+    return html`<div id="barfoo">some test module</div>`;
+  }
+}
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-user-test-popup': GrUserTestPopupElement;
+  }
+}
+
+const containerFixture = fixtureFromElement('div');
+
+suite('gr-popup-interface tests', () => {
+  let container: HTMLElement;
+  let instance: GrPopupInterface;
+  let plugin: PluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    container = containerFixture.instantiate();
+    sinon.stub(plugin, 'hook').returns({
+      getLastAttached() {
+        return Promise.resolve(container);
+      },
+    } as HookApi<PluginElement>);
+  });
+
+  suite('manual', () => {
+    setup(async () => {
+      instance = new GrPopupInterface(plugin);
+      await instance.open();
+    });
+
+    test('open', async () => {
+      const manual = document.createElement('div');
+      manual.id = 'foobar';
+      manual.innerHTML = 'manual content';
+      const popup = instance._getElement();
+      assert.isOk(popup);
+      popup!.appendChild(manual);
+      await flush();
+      assert.equal(
+        queryAndAssert(container, '#foobar').textContent,
+        'manual content'
+      );
+    });
+
+    test('close', async () => {
+      assert.isOk(instance._getElement());
+      assert.isTrue(instance._getElement()!.opened);
+      instance.close();
+      assert.isOk(instance._getElement());
+      assert.isFalse(instance._getElement()!.opened);
+    });
+  });
+
+  suite('components', () => {
+    setup(async () => {
+      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+      await instance.open();
+    });
+
+    test('open', async () => {
+      assert.isNotNull(container.querySelector('gr-user-test-popup'));
+    });
+
+    test('close', async () => {
+      assert.isOk(instance._getElement());
+      assert.isTrue(instance._getElement()!.opened);
+      instance.close();
+      assert.isOk(instance._getElement());
+      assert.isFalse(instance._getElement()!.opened);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 293fd14..bb6f256 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -19,117 +19,266 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-info_html';
-import {customElement, property, observe} from '@polymer/decorators';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {LitElement, css, html, nothing, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 
 @customElement('gr-account-info')
-export class GrAccountInfo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountInfo extends LitElement {
   /**
    * Fired when account details are changed.
    *
    * @event account-detail-update
    */
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-  })
-  usernameMutable?: boolean;
+  // private but used in test
+  @state() nameMutable?: boolean;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeNameMutable(_serverConfig)',
-  })
-  nameMutable?: boolean;
+  @property({type: Boolean}) hasUnsavedChanges = false;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed:
-      '_computeHasUnsavedChanges(_hasNameChange, ' +
-      '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
-  })
-  hasUnsavedChanges?: boolean;
+  // private but used in test
+  @state() hasNameChange = false;
 
-  @property({type: Boolean})
-  _hasNameChange?: boolean;
+  // private but used in test
+  @state() hasUsernameChange = false;
 
-  @property({type: Boolean})
-  _hasUsernameChange?: boolean;
+  // private but used in test
+  @state() hasDisplayNameChange = false;
 
-  @property({type: Boolean})
-  _hasDisplayNameChange?: boolean;
+  // private but used in test
+  @state() hasStatusChange = false;
 
-  @property({type: Boolean})
-  _hasStatusChange?: boolean;
+  // private but used in test
+  @state() loading = false;
 
-  @property({type: Boolean})
-  _loading = false;
+  @state() private saving = false;
 
-  @property({type: Boolean})
-  _saving = false;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
 
-  @property({type: Object})
-  _account?: AccountDetailInfo;
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  // private but used in test
+  @state() username?: string;
 
-  @property({type: String, observer: '_usernameChanged'})
-  _username?: string;
-
-  @property({type: String})
-  _avatarChangeUrl = '';
+  @state() private avatarChangeUrl = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      gr-avatar {
+        height: 120px;
+        width: 120px;
+        margin-right: var(--spacing-xs);
+        vertical-align: -0.25em;
+      }
+      div section.hide {
+        display: none;
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.account || this.loading) return nothing;
+    return html`<div class="gr-form-styles">
+      <section>
+        <span class="title"></span>
+        <span class="value">
+          <gr-avatar .account=${this.account} imageSize="120"></gr-avatar>
+        </span>
+      </section>
+      ${when(
+        this.avatarChangeUrl,
+        () => html` <section>
+          <span class="title"></span>
+          <span class="value">
+            <a href=${this.avatarChangeUrl}> Change avatar </a>
+          </span>
+        </section>`
+      )}
+      <section>
+        <span class="title">ID</span>
+        <span class="value">${this.account._account_id}</span>
+      </section>
+      <section>
+        <span class="title">Email</span>
+        <span class="value">${this.account.email}</span>
+      </section>
+      <section>
+        <span class="title">Registered</span>
+        <span class="value">
+          <gr-date-formatter
+            withTooltip
+            .dateStr=${this.account.registered_on}
+          ></gr-date-formatter>
+        </span>
+      </section>
+      <section id="usernameSection">
+        <span class="title">Username</span>
+        ${when(
+          this.computeUsernameEditable(),
+          () => html`<span class="value">
+            <iron-input
+              @keydown=${this.handleKeydown}
+              .bindValue=${this.username}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                if (this.username === e.detail.value) return;
+                this.username = e.detail.value;
+                this.hasUsernameChange = true;
+              }}
+              id="usernameIronInput"
+            >
+              <input
+                id="usernameInput"
+                ?disabled=${this.saving}
+                @keydown=${this.handleKeydown}
+              />
+            </iron-input>
+          </span>`,
+          () => html`<span class="value">${this.username}</span>`
+        )}
+      </section>
+      <section id="nameSection">
+        <label class="title" for="nameInput">Full name</label>
+        ${when(
+          this.nameMutable,
+          () => html`<span class="value">
+            <iron-input
+              @keydown=${this.handleKeydown}
+              .bindValue=${this.account?.name}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                const oldAccount = this.account;
+                if (!oldAccount || oldAccount.name === e.detail.value) return;
+                this.account = {...oldAccount, name: e.detail.value};
+                this.hasNameChange = true;
+              }}
+              id="nameIronInput"
+            >
+              <input
+                id="nameInput"
+                ?disabled=${this.saving}
+                @keydown=${this.handleKeydown}
+              />
+            </iron-input>
+          </span>`,
+          () => html` <span class="value">${this.account?.name}</span>`
+        )}
+      </section>
+      <section>
+        <label class="title" for="displayNameInput">Display name</label>
+        <span class="value">
+          <iron-input
+            @keydown=${this.handleKeydown}
+            .bindValue=${this.account.display_name}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              const oldAccount = this.account;
+              if (!oldAccount || oldAccount.display_name === e.detail.value) {
+                return;
+              }
+              this.account = {...oldAccount, display_name: e.detail.value};
+              this.hasDisplayNameChange = true;
+            }}
+          >
+            <input
+              id="displayNameInput"
+              ?disabled=${this.saving}
+              @keydown=${this.handleKeydown}
+            />
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <label class="title" for="statusInput">About me (e.g. employer)</label>
+        <span class="value">
+          <iron-input
+            id="statusIronInput"
+            @keydown=${this.handleKeydown}
+            .bindValue=${this.account?.status}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              const oldAccount = this.account;
+              if (!oldAccount || oldAccount.status === e.detail.value) return;
+              this.account = {...oldAccount, status: e.detail.value};
+              this.hasStatusChange = true;
+            }}
+          >
+            <input
+              id="statusInput"
+              ?disabled=${this.saving}
+              @keydown=${this.handleKeydown}
+            />
+          </iron-input>
+        </span>
+      </section>
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.nameMutable = this.computeNameMutable();
+    }
+
+    if (
+      changedProperties.has('hasNameChange') ||
+      changedProperties.has('hasUsernameChange') ||
+      changedProperties.has('hasStatusChange') ||
+      changedProperties.has('hasDisplayNameChange')
+    ) {
+      this.hasUnsavedChanges = this.computeHasUnsavedChanges();
+    }
+    if (changedProperties.has('hasUnsavedChanges')) {
+      fire(this, 'unsaved-changes-changed', {
+        value: this.hasUnsavedChanges,
+      });
+    }
+  }
+
   loadData() {
     const promises = [];
 
-    this._loading = true;
+    this.loading = true;
 
     promises.push(
       this.restApiService.getConfig().then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
       })
     );
 
-    promises.push(this.restApiService.invalidateAccountsDetailCache());
+    this.restApiService.invalidateAccountsDetailCache();
 
     promises.push(
       this.restApiService.getAccount().then(account => {
         if (!account) return;
-        this._hasNameChange = false;
-        this._hasUsernameChange = false;
-        this._hasDisplayNameChange = false;
-        this._hasStatusChange = false;
+        this.hasNameChange = false;
+        this.hasUsernameChange = false;
+        this.hasDisplayNameChange = false;
+        this.hasStatusChange = false;
         // Provide predefined value for username to trigger computation of
         // username mutability.
         account.username = account.username || '';
-        this._account = account;
-        this._username = account.username;
+        this.account = account;
+        this.username = account.username;
       })
     );
 
     promises.push(
       this.restApiService.getAvatarChangeUrl().then(url => {
-        this._avatarChangeUrl = url || '';
+        this.avatarChangeUrl = url || '';
       })
     );
 
     return Promise.all(promises).then(() => {
-      this._loading = false;
+      this.loading = false;
     });
   }
 
@@ -138,132 +287,90 @@
       return Promise.resolve();
     }
 
-    this._saving = true;
+    this.saving = true;
     // Set only the fields that have changed.
     // Must be done in sequence to avoid race conditions (@see Issue 5721)
-    return this._maybeSetName()
-      .then(() => this._maybeSetUsername())
-      .then(() => this._maybeSetDisplayName())
-      .then(() => this._maybeSetStatus())
+    return this.maybeSetName()
+      .then(() => this.maybeSetUsername())
+      .then(() => this.maybeSetDisplayName())
+      .then(() => this.maybeSetStatus())
       .then(() => {
-        this._hasNameChange = false;
-        this._hasDisplayNameChange = false;
-        this._hasStatusChange = false;
-        this._saving = false;
+        this.hasNameChange = false;
+        this.hasUsernameChange = false;
+        this.hasDisplayNameChange = false;
+        this.hasStatusChange = false;
+        this.saving = false;
         fireEvent(this, 'account-detail-update');
       });
   }
 
-  _maybeSetName() {
+  private maybeSetName() {
     // Note that we are intentionally not acting on this._account.name being the
     // empty string (which is falsy).
-    return this._hasNameChange && this.nameMutable && this._account?.name
-      ? this.restApiService.setAccountName(this._account.name)
+    return this.hasNameChange && this.nameMutable && this.account?.name
+      ? this.restApiService.setAccountName(this.account.name)
       : Promise.resolve();
   }
 
-  _maybeSetUsername() {
+  private maybeSetUsername() {
     // Note that we are intentionally not acting on this._username being the
     // empty string (which is falsy).
-    return this._hasUsernameChange && this.usernameMutable && this._username
-      ? this.restApiService.setAccountUsername(this._username)
+    return this.hasUsernameChange &&
+      this.computeUsernameEditable() &&
+      this.username
+      ? this.restApiService.setAccountUsername(this.username)
       : Promise.resolve();
   }
 
-  _maybeSetDisplayName() {
-    return this._hasDisplayNameChange &&
-      this._account?.display_name !== undefined
-      ? this.restApiService.setAccountDisplayName(this._account.display_name)
+  private maybeSetDisplayName() {
+    return this.hasDisplayNameChange && this.account?.display_name !== undefined
+      ? this.restApiService.setAccountDisplayName(this.account.display_name)
       : Promise.resolve();
   }
 
-  _maybeSetStatus() {
-    return this._hasStatusChange && this._account?.status !== undefined
-      ? this.restApiService.setAccountStatus(this._account.status)
+  private maybeSetStatus() {
+    return this.hasStatusChange && this.account?.status !== undefined
+      ? this.restApiService.setAccountStatus(this.account.status)
       : Promise.resolve();
   }
 
-  _computeHasUnsavedChanges(
-    nameChanged: boolean,
-    usernameChanged: boolean,
-    statusChanged: boolean,
-    displayNameChanged: boolean
-  ) {
+  private computeHasUnsavedChanges() {
     return (
-      nameChanged || usernameChanged || statusChanged || displayNameChanged
+      this.hasNameChange ||
+      this.hasUsernameChange ||
+      this.hasStatusChange ||
+      this.hasDisplayNameChange
     );
   }
 
-  _computeUsernameMutable(config: ServerInfo, username?: string) {
-    // Polymer 2: check for undefined
-    if ([config, username].includes(undefined)) {
-      return undefined;
-    }
-
-    // Username may not be changed once it is set.
+  // private but used in test
+  computeUsernameEditable() {
     return (
-      config.auth.editable_account_fields.includes(
+      !!this.serverConfig?.auth.editable_account_fields.includes(
         EditableAccountField.USER_NAME
-      ) && !username
+      ) && !this.account?.username
     );
   }
 
-  _computeNameMutable(config: ServerInfo) {
-    return config.auth.editable_account_fields.includes(
+  private computeNameMutable() {
+    return !!this.serverConfig?.auth.editable_account_fields.includes(
       EditableAccountField.FULL_NAME
     );
   }
 
-  @observe('_account.status')
-  _statusChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasStatusChange = true;
-  }
-
-  @observe('_account.display_name')
-  _displayNameChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasDisplayNameChange = true;
-  }
-
-  _usernameChanged() {
-    if (this._loading || !this._account) {
-      return;
-    }
-    this._hasUsernameChange =
-      (this._account.username || '') !== (this._username || '');
-  }
-
-  @observe('_account.name')
-  _nameChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasNameChange = true;
-  }
-
-  _handleKeydown(e: KeyboardEvent) {
+  private handleKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter
       e.stopPropagation();
       this.save();
     }
   }
-
-  _hideAvatarChangeUrl(avatarChangeUrl: string) {
-    if (!avatarChangeUrl) {
-      return 'hide';
-    }
-
-    return '';
-  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-account-info': GrAccountInfo;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
deleted file mode 100644
index a6ea1f6..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-avatar {
-      height: 120px;
-      width: 120px;
-      margin-right: var(--spacing-xs);
-      vertical-align: -0.25em;
-    }
-    div section.hide {
-      display: none;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <section>
-      <span class="title"></span>
-      <span class="value">
-        <gr-avatar account="[[_account]]" imageSize="120"></gr-avatar>
-      </span>
-    </section>
-    <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
-      <span class="title"></span>
-      <span class="value">
-        <a href$="[[_avatarChangeUrl]]"> Change avatar </a>
-      </span>
-    </section>
-    <section>
-      <span class="title">ID</span>
-      <span class="value">[[_account._account_id]]</span>
-    </section>
-    <section>
-      <span class="title">Email</span>
-      <span class="value">[[_account.email]]</span>
-    </section>
-    <section>
-      <span class="title">Registered</span>
-      <span class="value">
-        <gr-date-formatter
-          withTooltip
-          date-str="[[_account.registered_on]]"
-        ></gr-date-formatter>
-      </span>
-    </section>
-    <section id="usernameSection">
-      <span class="title">Username</span>
-      <span hidden$="[[usernameMutable]]" class="value">[[_username]]</span>
-      <span hidden$="[[!usernameMutable]]" class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_username}}"
-          id="usernameIronInput"
-        >
-          <input
-            id="usernameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section id="nameSection">
-      <label class="title" for="nameInput">Full name</label>
-      <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
-      <span hidden$="[[!nameMutable]]" class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.name}}"
-          id="nameIronInput"
-        >
-          <input
-            id="nameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label class="title" for="displayNameInput">Display name</label>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.display_name}}"
-        >
-          <input
-            id="displayNameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label class="title" for="statusInput">About me (e.g. employer)</label>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.status}}"
-        >
-          <input
-            id="statusInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index a12d289..03ad8a5 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -17,18 +17,20 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-account-info';
-import {SinonSpyMember, stubRestApi} from '../../../test/test-utils';
+import {query, queryAll, stubRestApi} from '../../../test/test-utils';
 import {GrAccountInfo} from './gr-account-info';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
   createAccountDetailWithId,
   createAccountWithIdNameAndEmail,
+  createAuth,
   createPreferences,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {IronInputElement} from '@polymer/iron-input';
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../api/rest-api';
 
 const basicFixture = fixtureFromElement('gr-account-info');
 
@@ -38,13 +40,13 @@
   let config: ServerInfo;
 
   function queryIronInput(selector: string): IronInputElement {
-    const input = element.root?.querySelector<IronInputElement>(selector);
+    const input = query<IronInputElement>(element, selector);
     if (!input) assert.fail(`<iron-input> with id ${selector} not found.`);
     return input;
   }
 
   function valueOf(title: string): Element {
-    const sections = element.root?.querySelectorAll('section') ?? [];
+    const sections = queryAll<HTMLElement>(element, 'section') ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -66,7 +68,7 @@
 
     element = basicFixture.instantiate();
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -78,10 +80,6 @@
             <gr-avatar hidden="" imagesize="120"></gr-avatar>
           </span>
         </section>
-        <section class="hide">
-          <span class="title"></span>
-          <span class="value"><a href="">Change avatar</a></span>
-        </section>
         <section>
           <span class="title">ID</span>
           <span class="value">123</span>
@@ -99,20 +97,10 @@
         <section id="usernameSection">
           <span class="title">Username</span>
           <span class="value"></span>
-          <span class="value" hidden="true">
-            <iron-input id="usernameIronInput">
-              <input id="usernameInput" />
-            </iron-input>
-          </span>
         </section>
         <section id="nameSection">
           <label class="title" for="nameInput">Full name</label>
           <span class="value">User-123</span>
-          <span class="value" hidden="true">
-            <iron-input id="nameIronInput">
-              <input id="nameInput" />
-            </iron-input>
-          </span>
         </section>
         <section>
           <label class="title" for="displayNameInput">Display name</label>
@@ -127,7 +115,7 @@
             About me (e.g. employer)
           </label>
           <span class="value">
-            <iron-input>
+            <iron-input id="statusIronInput">
               <input id="statusInput" />
             </iron-input>
           </span>
@@ -137,7 +125,7 @@
   });
 
   test('basic account info render', () => {
-    assert.isFalse(element._loading);
+    assert.isFalse(element.loading);
 
     assert.equal(valueOf('ID').textContent, `${account._account_id}`);
     assert.equal(valueOf('Email').textContent, account.email);
@@ -145,55 +133,62 @@
   });
 
   test('full name render (immutable)', () => {
-    const section = element.$.nameSection;
+    const section = query<HTMLElement>(element, '#nameSection')!;
     const displaySpan = section.querySelectorAll('.value')[0];
     const inputSpan = section.querySelectorAll('.value')[1];
 
     assert.isFalse(element.nameMutable);
     assert.isFalse(displaySpan.hasAttribute('hidden'));
     assert.equal(displaySpan.textContent, account.name);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
+    assert.isUndefined(inputSpan);
   });
 
-  test('full name render (mutable)', () => {
-    element.set('_serverConfig', {
-      auth: {editable_account_fields: ['FULL_NAME']},
-    });
+  test('full name render (mutable)', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.FULL_NAME],
+      },
+    };
 
-    const section = element.$.nameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
+    await element.updateComplete;
+    const section = query<HTMLElement>(element, '#nameSection')!;
+    const inputSpan = section.querySelectorAll('.value')[0];
 
     assert.isTrue(element.nameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
     assert.equal(queryIronInput('#nameIronInput').bindValue, account.name);
     assert.isFalse(inputSpan.hasAttribute('hidden'));
   });
 
   test('username render (immutable)', () => {
-    const section = element.$.usernameSection;
+    const section = query<HTMLElement>(element, '#usernameSection')!;
     const displaySpan = section.querySelectorAll('.value')[0];
     const inputSpan = section.querySelectorAll('.value')[1];
 
-    assert.isFalse(element.usernameMutable);
+    assert.isFalse(element.computeUsernameEditable());
     assert.isFalse(displaySpan.hasAttribute('hidden'));
     assert.equal(displaySpan.textContent, account.username);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
+    assert.isUndefined(inputSpan);
   });
 
-  test('username render (mutable)', () => {
-    element.set('_serverConfig', {
-      auth: {editable_account_fields: ['USER_NAME']},
-    });
-    element.set('_account.username', '');
-    element.set('_username', '');
+  test('username render (mutable)', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    element.account!.username = '';
+    element.username = '';
 
-    const section = element.$.usernameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
+    await element.updateComplete;
 
-    assert.isTrue(element.usernameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    const section = query<HTMLElement>(element, '#usernameSection')!;
+    const inputSpan = section.querySelectorAll('.value')[0];
+
+    assert.isTrue(element.computeUsernameEditable());
     assert.equal(
       queryIronInput('#usernameIronInput').bindValue,
       account.username
@@ -202,21 +197,23 @@
   });
 
   suite('account info edit', () => {
-    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
-    let usernameChangedSpy: SinonSpyMember<typeof element._usernameChanged>;
-    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
     let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
     let usernameStub: SinonStubbedMember<RestApiService['setAccountUsername']>;
     let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
-    setup(() => {
-      nameChangedSpy = sinon.spy(element, '_nameChanged');
-      usernameChangedSpy = sinon.spy(element, '_usernameChanged');
-      statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig', {
-        auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']},
-      });
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [
+            EditableAccountField.FULL_NAME,
+            EditableAccountField.USER_NAME,
+          ],
+        },
+      };
 
+      await element.updateComplete;
       nameStub = stubRestApi('setAccountName').resolves();
       usernameStub = stubRestApi('setAccountUsername').resolves();
       statusStub = stubRestApi('setAccountStatus').resolves();
@@ -226,10 +223,11 @@
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.name', 'new name');
-
-      assert.isTrue(nameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#nameIronInput');
+      statusInputEl.bindValue = 'new name';
+      await element.updateComplete;
+      assert.isTrue(element.hasNameChange);
+      assert.isFalse(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -241,14 +239,25 @@
     });
 
     test('username', async () => {
-      element.set('_account.username', '');
-      element._hasUsernameChange = false;
-      assert.isTrue(element.usernameMutable);
+      element.account!.username = '';
+      element.username = 't';
+      element.hasUsernameChange = false;
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [EditableAccountField.USER_NAME],
+        },
+      };
+      await element.updateComplete;
+      assert.isTrue(element.computeUsernameEditable());
 
-      element.set('_username', 'new username');
-
-      assert.isTrue(usernameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#usernameIronInput');
+      statusInputEl.bindValue = 'new username';
+      await element.updateComplete;
+      assert.isTrue(element.hasUsernameChange);
+      assert.isFalse(element.hasNameChange);
+      assert.isFalse(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -262,10 +271,11 @@
     test('status', async () => {
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.status', 'new status');
-
-      assert.isFalse(nameChangedSpy.called);
-      assert.isTrue(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#statusIronInput');
+      statusInputEl.bindValue = 'new status';
+      await element.updateComplete;
+      assert.isFalse(element.hasNameChange);
+      assert.isTrue(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -278,17 +288,18 @@
   });
 
   suite('edit name and status', () => {
-    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
-    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
     let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
     let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
-    setup(() => {
-      nameChangedSpy = sinon.spy(element, '_nameChanged');
-      statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig', {
-        auth: {editable_account_fields: ['FULL_NAME']},
-      });
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [EditableAccountField.FULL_NAME],
+        },
+      };
+      await element.updateComplete;
 
       nameStub = stubRestApi('setAccountName').resolves();
       statusStub = stubRestApi('setAccountStatus').resolves();
@@ -299,13 +310,15 @@
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.name', 'new name');
+      const inputEl = queryIronInput('#nameIronInput');
+      inputEl.bindValue = 'new name';
+      await element.updateComplete;
+      assert.isTrue(element.hasNameChange);
 
-      assert.isTrue(nameChangedSpy.called);
-
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#statusIronInput');
+      statusInputEl.bindValue = 'new status';
+      await element.updateComplete;
+      assert.isTrue(element.hasStatusChange);
 
       assert.isTrue(element.hasUnsavedChanges);
 
@@ -320,18 +333,23 @@
   });
 
   suite('set status but read name', () => {
-    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
     let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
-    setup(() => {
-      statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig', {auth: {editable_account_fields: []}});
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [],
+        },
+      };
+      await element.updateComplete;
 
       statusStub = stubRestApi('setAccountStatus').resolves();
     });
 
     test('read full name but set status', async () => {
-      const section = element.$.nameSection;
+      const section = query<HTMLElement>(element, '#nameSection')!;
       const displaySpan = section.querySelectorAll('.value')[0];
       const inputSpan = section.querySelectorAll('.value')[1];
 
@@ -341,11 +359,12 @@
 
       assert.isFalse(displaySpan.hasAttribute('hidden'));
       assert.equal(displaySpan.textContent, account.name);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
+      assert.isUndefined(inputSpan);
 
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
+      const inputEl = queryIronInput('#statusIronInput');
+      inputEl.bindValue = 'new status';
+      await element.updateComplete;
+      assert.isTrue(element.hasStatusChange);
 
       assert.isTrue(element.hasUnsavedChanges);
 
@@ -356,27 +375,27 @@
     });
   });
 
-  test('_usernameChanged compares usernames with loose equality', () => {
-    element._account = createAccountDetailWithId();
-    element._username = '';
-    element._hasUsernameChange = false;
-    element._loading = false;
-    // _usernameChanged is an observer, but call it here after setting
-    // _hasUsernameChange in the test to force recomputation.
-    element._usernameChanged();
-    flush();
+  test('_usernameChanged compares usernames with loose equality', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    element.account = createAccountDetailWithId();
+    element.username = 't';
+    element.hasUsernameChange = false;
+    element.loading = false;
+    // usernameChanged is an observer, but call it here after setting
+    // hasUsernameChange in the test to force recomputation.
+    await element.updateComplete;
+    assert.isFalse(element.hasUsernameChange);
 
-    assert.isFalse(element._hasUsernameChange);
+    const inputEl = queryIronInput('#usernameIronInput');
+    inputEl.bindValue = 'test';
+    await element.updateComplete;
 
-    element.set('_username', 'test');
-    flush();
-
-    assert.isTrue(element._hasUsernameChange);
-  });
-
-  test('_hideAvatarChangeUrl', () => {
-    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
-
-    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+    assert.isTrue(element.hasUsernameChange);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
index 2db7c76..96b8d12 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -62,7 +62,7 @@
     return html`
       <tr>
         <td class="nameColumn">
-          <a href="${this.getUrlBase(agreement?.url)}" rel="external">
+          <a href=${this.getUrlBase(agreement?.url)} rel="external">
             ${agreement.name}
           </a>
         </td>
@@ -86,7 +86,7 @@
           )}
         </tbody>
       </table>
-      <a href="${this.getUrl()}">New Contributor Agreement</a>
+      <a href=${this.getUrl()}>New Contributor Agreement</a>
     </div>`;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index bfe396c..a09cdbc 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -15,27 +15,23 @@
  * limitations under the License.
  */
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-table-editor_html';
-import {customElement, property, observe} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {PropertyValues} from 'lit';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {ColumnNames} from '../../../constants/constants';
 
 @customElement('gr-change-table-editor')
-export class GrChangeTableEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Array, notify: true})
+export class GrChangeTableEditor extends LitElement {
+  @property({type: Array})
   displayedColumns: string[] = [];
 
-  @property({type: Boolean, notify: true})
+  @property({type: Boolean})
   showNumber?: boolean;
 
   @property({type: Object})
@@ -46,64 +42,131 @@
 
   private readonly flagsService = getAppContext().flagsService;
 
-  @observe('serverConfig')
-  _configChanged(config: ServerInfo) {
-    this.defaultColumns = columnNames.filter(col =>
-      this._isColumnEnabled(col, config)
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      #changeCols {
+        width: auto;
+      }
+      #changeCols .visibleHeader {
+        text-align: center;
+      }
+      .checkboxContainer {
+        cursor: pointer;
+        text-align: center;
+      }
+      .checkboxContainer input {
+        cursor: pointer;
+      }
+      .checkboxContainer:hover {
+        outline: 1px solid var(--border-color);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+      <table id="changeCols">
+        <thead>
+          <tr>
+            <th class="nameHeader">Column</th>
+            <th class="visibleHeader">Visible</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><label for="numberCheckbox">Number</label></td>
+            <td
+              class="checkboxContainer"
+              @click=${this.handleCheckboxContainerClick}
+            >
+              <input
+                id="numberCheckbox"
+                type="checkbox"
+                name="number"
+                @click=${this.handleNumberCheckboxClick}
+                ?checked=${this.showNumber}
+              />
+            </td>
+          </tr>
+          ${this.defaultColumns.map(column => this.renderRow(column))}
+        </tbody>
+      </table>
+    </div>`;
+  }
+
+  renderRow(column: string) {
+    return html`<tr>
+      <td><label for=${column}>${column}</label></td>
+      <td class="checkboxContainer" @click=${this.handleCheckboxContainerClick}>
+        <input
+          id=${column}
+          type="checkbox"
+          name=${column}
+          @click=${this.handleTargetClick}
+          ?checked=${!this.computeIsColumnHidden(column)}
+        />
+      </td>
+    </tr>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.configChanged();
+    }
+  }
+
+  private configChanged() {
+    this.defaultColumns = Object.values(ColumnNames).filter(column =>
+      this.isColumnEnabled(column)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this._isColumnEnabled(column, config)
+      this.isColumnEnabled(column)
     );
   }
 
   /**
    * Is the column disabled by a server config or experiment?
+   * private but used in test
    */
-  _isColumnEnabled(column: string, config: ServerInfo) {
-    if (!config || !config.change) return true;
-    if (column === 'Comments')
+  isColumnEnabled(column: string) {
+    if (!this.serverConfig?.change) return true;
+    if (column === ColumnNames.COMMENTS)
       return this.flagsService.isEnabled('comments-column');
-    if (column === 'Status')
-      return !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    if (column === ' Status ')
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === ColumnNames.STATUS) return false;
     return true;
   }
 
   /**
    * Get the list of enabled column names from whichever checkboxes are
    * checked (excluding the number checkbox).
+   * private but used in test
    */
-  _getDisplayedColumns() {
-    if (this.root === null) return [];
-    return (
-      Array.from(
-        this.root.querySelectorAll(
-          '.checkboxContainer input:not([name=number])'
-        )
-      ) as HTMLInputElement[]
+  getDisplayedColumns() {
+    if (this.shadowRoot === null) return [];
+    return Array.from(
+      this.shadowRoot.querySelectorAll<HTMLInputElement>(
+        '.checkboxContainer input:not([name=number])'
+      )
     )
       .filter(checkbox => checkbox.checked)
       .map(checkbox => checkbox.name);
   }
 
-  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
-    if (!columnsToDisplay || !columnToCheck) {
+  private computeIsColumnHidden(columnToCheck?: string) {
+    if (!this.displayedColumns || !columnToCheck) {
       return false;
     }
-    return !columnsToDisplay.includes(columnToCheck);
+    return !this.displayedColumns.includes(columnToCheck);
   }
 
   /**
    * Handle a click on a checkbox container and relay the click to the checkbox it
    * contains.
    */
-  _handleCheckboxContainerClick(e: MouseEvent) {
+  private handleCheckboxContainerClick(e: MouseEvent) {
     if (e.target === null) return;
     const checkbox = (e.target as HTMLElement).querySelector('input');
     if (!checkbox) {
@@ -116,22 +179,26 @@
    * Handle a click on the number checkbox and update the showNumber property
    * accordingly.
    */
-  _handleNumberCheckboxClick(e: MouseEvent) {
-    this.showNumber = (
-      (dom(e) as EventApi).rootTarget as HTMLInputElement
-    ).checked;
+  private handleNumberCheckboxClick(e: MouseEvent) {
+    this.showNumber = (e.target as HTMLInputElement).checked;
+    fire(this, 'show-number-changed', {value: this.showNumber});
   }
 
   /**
    * Handle a click on a displayed column checkboxes (excluding number) and
    * update the displayedColumns property accordingly.
    */
-  _handleTargetClick() {
-    this.set('displayedColumns', this._getDisplayedColumns());
+  private handleTargetClick() {
+    this.displayedColumns = this.getDisplayedColumns();
+    fire(this, 'displayed-columns-changed', {value: this.displayedColumns});
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'show-number-changed': ValueChangedEvent<boolean>;
+    'displayed-columns-changed': ValueChangedEvent<string[]>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-table-editor': GrChangeTableEditor;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
deleted file mode 100644
index e756a20..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #changeCols {
-      width: auto;
-    }
-    #changeCols .visibleHeader {
-      text-align: center;
-    }
-    .checkboxContainer {
-      cursor: pointer;
-      text-align: center;
-    }
-    .checkboxContainer input {
-      cursor: pointer;
-    }
-    .checkboxContainer:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="changeCols">
-      <thead>
-        <tr>
-          <th class="nameHeader">Column</th>
-          <th class="visibleHeader">Visible</th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr>
-          <td><label for="numberCheckbox">Number</label></td>
-          <td
-            class="checkboxContainer"
-            on-click="_handleCheckboxContainerClick"
-          >
-            <input
-              id="numberCheckbox"
-              type="checkbox"
-              name="number"
-              on-click="_handleNumberCheckboxClick"
-              checked$="[[showNumber]]"
-            />
-          </td>
-        </tr>
-        <template is="dom-repeat" items="[[defaultColumns]]">
-          <tr>
-            <td><label for$="[[item]]">[[item]]</label></td>
-            <td
-              class="checkboxContainer"
-              on-click="_handleCheckboxContainerClick"
-            >
-              <input
-                id$="[[item]]"
-                type="checkbox"
-                name="[[item]]"
-                on-click="_handleTargetClick"
-                checked$="[[!_computeIsColumnHidden(item, displayedColumns)]]"
-              />
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index c2bcec2..42ef8f4 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -14,21 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
 import './gr-change-table-editor';
 import {GrChangeTableEditor} from './gr-change-table-editor';
 import {queryAndAssert} from '../../../test/test-utils';
 import {createServerInfo} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-change-table-editor');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-table-editor tests', () => {
   let element: GrChangeTableEditor;
   let columns: string[];
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrChangeTableEditor>(
+      html`<gr-change-table-editor></gr-change-table-editor>`
+    );
 
     columns = [
       'Subject',
@@ -37,14 +38,88 @@
       'Reviewers',
       'Comments',
       'Repo',
-      'Branch',
-      'Updated',
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
     ];
 
-    element.set('displayedColumns', columns);
+    element.displayedColumns = columns;
     element.showNumber = false;
     element.serverConfig = createServerInfo();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ ` <div class="gr-form-styles">
+      <table id="changeCols">
+        <thead>
+          <tr>
+            <th class="nameHeader">Column</th>
+            <th class="visibleHeader">Visible</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><label for="numberCheckbox"> Number </label></td>
+            <td class="checkboxContainer">
+              <input id="numberCheckbox" name="number" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Subject"> Subject </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Subject" name="Subject" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Owner"> Owner </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Owner" name="Owner" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Reviewers"> Reviewers </label></td>
+            <td class="checkboxContainer">
+              <input
+                checked=""
+                id="Reviewers"
+                name="Reviewers"
+                type="checkbox"
+              />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Repo"> Repo </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Repo" name="Repo" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Branch"> Branch </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Branch" name="Branch" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Updated"> Updated </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Updated" name="Updated" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Size"> Size </label></td>
+            <td class="checkboxContainer">
+              <input id="Size" name="Size" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for=" Status "> Status </label></td>
+            <td class="checkboxContainer">
+              <input id=" Status " name=" Status " type="checkbox" />
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>`);
   });
 
   test('renders', () => {
@@ -60,7 +135,7 @@
     }
   });
 
-  test('hide item', () => {
+  test('hide item', async () => {
     const checkbox = queryAndAssert<HTMLInputElement>(
       element,
       'table tr:nth-child(2) input'
@@ -69,23 +144,23 @@
     const displayedLength = element.displayedColumns.length;
     assert.isTrue(isChecked);
 
-    MockInteractions.tap(checkbox);
-    flush();
+    checkbox.click();
+    await element.updateComplete;
 
     assert.equal(element.displayedColumns.length, displayedLength - 1);
   });
 
-  test('show item', () => {
-    element.set('displayedColumns', [
-      'Status',
-      'Owner',
-      'Repo',
-      'Branch',
-      'Updated',
-    ]);
+  test('show item', async () => {
+    element.displayedColumns = [
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+    ];
     // trigger computation of enabled displayed columns
     element.serverConfig = createServerInfo();
-    flush();
+    await element.updateComplete;
     const checkbox = queryAndAssert<HTMLInputElement>(
       element,
       'table tr:nth-child(2) input'
@@ -96,74 +171,68 @@
     const table = queryAndAssert<HTMLTableElement>(element, 'table');
     assert.equal(table.style.display, '');
 
-    MockInteractions.tap(checkbox);
-    flush();
+    checkbox.click();
+    await element.updateComplete;
 
     assert.equal(element.displayedColumns.length, displayedLength + 1);
   });
 
-  test('_getDisplayedColumns', () => {
+  test('getDisplayedColumns', () => {
     const enabledColumns = columns.filter(column =>
-      element._isColumnEnabled(column, element.serverConfig!)
+      element.isColumnEnabled(column)
     );
-    assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
+    assert.deepEqual(element.getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=Subject]'
     );
-    MockInteractions.tap(input);
+    input.click();
     assert.deepEqual(
-      element._getDisplayedColumns(),
+      element.getDisplayedColumns(),
       enabledColumns.filter(c => c !== 'Subject')
     );
   });
 
-  test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
-    const checkBoxClickStub = sinon.stub(element, '_handleNumberCheckboxClick');
-    const targetClickStub = sinon.stub(element, '_handleTargetClick');
-
-    const firstContainer = queryAndAssert(
+  test('handleCheckboxContainerClick relays taps to checkboxes', async () => {
+    const firstContainer = queryAndAssert<HTMLTableRowElement>(
       element,
       'table tr:first-of-type .checkboxContainer'
     );
-    MockInteractions.tap(firstContainer);
-    assert.isTrue(checkBoxClickStub.calledOnce);
-    assert.isFalse(targetClickStub.called);
+    assert.isFalse(element.showNumber);
+    firstContainer.click();
+    assert.isTrue(element.showNumber);
 
-    const lastContainer = queryAndAssert(
+    const lastContainer = queryAndAssert<HTMLTableRowElement>(
       element,
       'table tr:last-of-type .checkboxContainer'
     );
-    MockInteractions.tap(lastContainer);
-    assert.isTrue(checkBoxClickStub.calledOnce);
-    assert.isTrue(targetClickStub.calledOnce);
+    const lastColumn =
+      element.defaultColumns[element.defaultColumns.length - 1];
+    assert.notInclude(element.displayedColumns, lastColumn);
+    lastContainer.click();
+    await element.updateComplete;
+    assert.include(element.displayedColumns, lastColumn);
   });
 
-  test('_handleNumberCheckboxClick', () => {
-    const checkBoxClickSpy = sinon.spy(element, '_handleNumberCheckboxClick');
-
-    const numberInput = queryAndAssert(
+  test('handleNumberCheckboxClick', () => {
+    const numberInput = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=number]'
     );
-    MockInteractions.tap(numberInput);
-    assert.isTrue(checkBoxClickSpy.calledOnce);
+    numberInput.click();
     assert.isTrue(element.showNumber);
 
-    MockInteractions.tap(numberInput);
-    assert.isTrue(checkBoxClickSpy.calledTwice);
+    numberInput.click();
     assert.isFalse(element.showNumber);
   });
 
-  test('_handleTargetClick', () => {
-    const targetClickSpy = sinon.spy(element, '_handleTargetClick');
+  test('handleTargetClick', () => {
     assert.include(element.displayedColumns, 'Subject');
-    const subjectInput = queryAndAssert(
+    const subjectInput = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=Subject]'
     );
-    MockInteractions.tap(subjectInput);
-    assert.isTrue(targetClickSpy.calledOnce);
+    subjectInput.click();
     assert.notInclude(element.displayedColumns, 'Subject');
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 677e0c1..f429c22 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -16,14 +16,8 @@
  */
 
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-cla-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {
   ServerInfo,
   GroupInfo,
@@ -31,6 +25,13 @@
 } from '../../../types/common';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {ifDefined} from 'lit/directives/if-defined';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,31 +40,24 @@
 }
 
 @customElement('gr-cla-view')
-export class GrClaView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrClaView extends LitElement {
+  // private but used in test
+  @state() groups?: GroupInfo[];
 
-  @property({type: Object})
-  _groups?: GroupInfo[];
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @state() private agreementsText?: string;
 
-  @property({type: String})
-  _agreementsText?: string;
+  // private but used in test
+  @state() agreementName?: string;
 
-  @property({type: String})
-  _agreementName?: string;
+  // private but used in test
+  @state() signedAgreements?: ContributorAgreementInfo[];
 
-  @property({type: Array})
-  _signedAgreements?: ContributorAgreementInfo[];
+  @state() private showAgreements = false;
 
-  @property({type: Boolean})
-  _showAgreements = false;
-
-  @property({type: String})
-  _agreementsUrl?: string;
+  @state() private agreementsUrl?: string;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -74,18 +68,142 @@
     fireTitleChange(this, 'New Contributor Agreement');
   }
 
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      sharedStyles,
+      css`
+        h1 {
+          margin-bottom: var(--spacing-m);
+        }
+        h3 {
+          margin-bottom: var(--spacing-m);
+        }
+        .agreementsUrl {
+          border: 1px solid var(--border-color);
+          margin-bottom: var(--spacing-xl);
+          margin-left: var(--spacing-xl);
+          margin-right: var(--spacing-xl);
+          padding: var(--spacing-s);
+        }
+        #claNewAgreementsLabel {
+          font-weight: var(--font-weight-bold);
+        }
+        #claNewAgreement {
+          display: none;
+        }
+        #claNewAgreement.show {
+          display: block;
+        }
+        .contributorAgreementButton {
+          font-weight: var(--font-weight-bold);
+        }
+        .alreadySubmittedText {
+          color: var(--error-text-color);
+          margin: 0 var(--spacing-xxl);
+          padding: var(--spacing-m);
+        }
+        main {
+          margin: var(--spacing-xxl) auto;
+          max-width: 50em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <main>
+        <h1 class="heading-1">New Contributor Agreement</h1>
+        <h3 class="heading-3">Select an agreement type:</h3>
+        ${(this.serverConfig?.auth.contributor_agreements ?? [])
+          .filter(agreement => agreement.url)
+          .map(item => this.renderAgreementsButton(item))}
+        ${this.renderNewAgreement()}
+      </main>
+    `;
+  }
+
+  private renderAgreementsButton(item: ContributorAgreementInfo) {
+    return html`
+      <span class="contributorAgreementButton">
+        <input
+          id="claNewAgreementsInput${item.name}"
+          name="claNewAgreementsRadio"
+          type="radio"
+          data-name=${ifDefined(item.name)}
+          data-url=${ifDefined(item.url)}
+          @click=${this.handleShowAgreement}
+          ?disabled=${this.disableAgreements(item)}
+        />
+        <label id="claNewAgreementsLabel">${item.name}</label>
+      </span>
+      ${this.renderAlreadySubmittedText(item)}
+      <div class="agreementsUrl">${item.description}</div>
+    `;
+  }
+
+  private renderAlreadySubmittedText(item: ContributorAgreementInfo) {
+    if (!this.disableAgreements(item)) return;
+
+    return html`
+      <div class="alreadySubmittedText">Agreement already submitted.</div>
+    `;
+  }
+
+  private renderNewAgreement() {
+    if (!this.showAgreements) return;
+    return html`
+      <div id="claNewAgreement">
+        <h3 class="heading-3">Review the agreement:</h3>
+        <div id="agreementsUrl" class="agreementsUrl">
+          <a
+            href=${ifDefined(this.agreementsUrl)}
+            target="blank"
+            rel="noopener"
+          >
+            Please review the agreement.</a
+          >
+        </div>
+        ${this.renderAgreementsTextBox()} ${this.computeHideAgreementTextbox()}
+      </div>
+    `;
+  }
+
+  private renderAgreementsTextBox() {
+    if (this.computeHideAgreementTextbox()) return;
+    return html`
+      <div class="agreementsTextBox">
+        <h3 class="heading-3">Complete the agreement:</h3>
+        <iron-input
+          .bindValue=${this.agreementsText}
+          @bind-value-changed=${this.handleBindValueChanged}
+        >
+          <input id="input-agreements" placeholder="Enter 'I agree' here" />
+        </iron-input>
+        <gr-button
+          @click=${this.handleSaveAgreements}
+          ?disabled=${this.agreementsText?.toLowerCase() !== 'i agree'}
+        >
+          Submit
+        </gr-button>
+      </div>
+    `;
+  }
+
   loadData() {
     const promises = [];
     promises.push(
       this.restApiService.getConfig(true).then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
       })
     );
 
     promises.push(
       this.restApiService.getAccountGroups().then(groups => {
         if (!groups) return;
-        this._groups = groups.sort((a, b) =>
+        this.groups = groups.sort((a, b) =>
           (a.name || '').localeCompare(b.name || '')
         );
       })
@@ -95,18 +213,17 @@
       this.restApiService
         .getAccountAgreements()
         .then((agreements: ContributorAgreementInfo[] | undefined) => {
-          this._signedAgreements = agreements || [];
+          this.signedAgreements = agreements || [];
         })
     );
 
     return Promise.all(promises);
   }
 
-  _getAgreementsUrl(configUrl: string) {
+  // private but used in test
+  getAgreementsUrl(configUrl: string) {
+    if (!configUrl) return '';
     let url;
-    if (!configUrl) {
-      return '';
-    }
     if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
       url = configUrl;
     } else {
@@ -116,49 +233,41 @@
     return url;
   }
 
-  _handleShowAgreement(e: Event) {
-    this._agreementName = (e.target as HTMLInputElement).getAttribute(
+  private readonly handleShowAgreement = (e: Event) => {
+    this.agreementName = (e.target as HTMLInputElement).getAttribute(
       'data-name'
     )!;
     const url = (e.target as HTMLInputElement).getAttribute('data-url')!;
-    this._agreementsUrl = this._getAgreementsUrl(url);
-    this._showAgreements = true;
-  }
+    this.agreementsUrl = this.getAgreementsUrl(url);
+    this.showAgreements = true;
+  };
 
-  _handleSaveAgreements() {
-    this._createToast('Agreement saving...');
+  private readonly handleSaveAgreements = () => {
+    this.createToast('Agreement saving...');
 
-    const name = this._agreementName;
+    const name = this.agreementName;
     return this.restApiService.saveAccountAgreement({name}).then(res => {
       let message = 'Agreement failed to be submitted, please try again';
       if (res.status === 200) {
         message = 'Agreement has been successfully submitted.';
       }
-      this._createToast(message);
+      this.createToast(message);
       this.loadData();
-      this._agreementsText = '';
-      this._showAgreements = false;
+      this.agreementsText = '';
+      this.showAgreements = false;
     });
-  }
+  };
 
-  _createToast(message: string) {
+  private createToast(message: string) {
     fireAlert(this, message);
   }
 
-  _computeShowAgreementsClass(showAgreements: boolean) {
-    return showAgreements ? 'show' : '';
-  }
-
-  _disableAgreements(
-    item: ContributorAgreementInfo,
-    groups?: GroupInfo[],
-    signedAgreements?: ContributorAgreementInfo[]
-  ) {
-    if (!groups) return false;
-    for (const group of groups) {
+  // private but used in test
+  disableAgreements(item: ContributorAgreementInfo) {
+    for (const group of this.groups ?? []) {
       if (
         item?.auto_verify_group?.id === group.id ||
-        signedAgreements?.find(i => i.name === item.name)
+        this.signedAgreements?.find(i => i.name === item.name)
       ) {
         return true;
       }
@@ -166,40 +275,22 @@
     return false;
   }
 
-  _hideAgreements(
-    item: ContributorAgreementInfo,
-    groups?: GroupInfo[],
-    signedAgreements?: ContributorAgreementInfo[]
-  ) {
-    return this._disableAgreements(item, groups, signedAgreements)
-      ? ''
-      : 'hide';
-  }
-
-  _disableAgreementsText(text?: string) {
-    return text?.toLowerCase() === 'i agree' ? false : true;
-  }
-
   // This checks for auto_verify_group,
-  // if specified it returns 'hideAgreementsTextBox' which
+  // if specified it returns 'true' which
   // then hides the text box and submit button.
-  _computeHideAgreementClass(
-    name?: string,
-    contributorAgreements?: ContributorAgreementInfo[]
-  ) {
-    if (!name || !contributorAgreements) return '';
+  // private but used in test
+  computeHideAgreementTextbox() {
+    const contributorAgreements =
+      this.serverConfig?.auth.contributor_agreements;
+    if (!this.agreementName || !contributorAgreements) return false;
     return contributorAgreements.some(
       (contributorAgreement: ContributorAgreementInfo) =>
-        name === contributorAgreement.name &&
+        this.agreementName === contributorAgreement.name &&
         !contributorAgreement.auto_verify_group
-    )
-      ? 'hideAgreementsTextBox'
-      : '';
-  }
-
-  _computeAgreements(serverConfig?: ServerInfo) {
-    return (serverConfig?.auth.contributor_agreements ?? []).filter(
-      agreement => agreement.url
     );
   }
+
+  private readonly handleBindValueChanged = (e: BindValueChangeEvent) => {
+    this.agreementsText = e.detail.value;
+  };
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
deleted file mode 100644
index 564297fa..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    h1 {
-      margin-bottom: var(--spacing-m);
-    }
-    h3 {
-      margin-bottom: var(--spacing-m);
-    }
-    .agreementsUrl {
-      border: 1px solid var(--border-color);
-      margin-bottom: var(--spacing-xl);
-      margin-left: var(--spacing-xl);
-      margin-right: var(--spacing-xl);
-      padding: var(--spacing-s);
-    }
-    #claNewAgreementsLabel {
-      font-weight: var(--font-weight-bold);
-    }
-    #claNewAgreement {
-      display: none;
-    }
-    #claNewAgreement.show {
-      display: block;
-    }
-    .contributorAgreementButton {
-      font-weight: var(--font-weight-bold);
-    }
-    .alreadySubmittedText {
-      color: var(--error-text-color);
-      margin: 0 var(--spacing-xxl);
-      padding: var(--spacing-m);
-    }
-    .alreadySubmittedText.hide,
-    .hideAgreementsTextBox {
-      display: none;
-    }
-    main {
-      margin: var(--spacing-xxl) auto;
-      max-width: 50em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <main>
-    <h1 class="heading-1">New Contributor Agreement</h1>
-    <h3 class="heading-3">Select an agreement type:</h3>
-    <template is="dom-repeat" items="[[_computeAgreements(_serverConfig)]]">
-      <span class="contributorAgreementButton">
-        <input
-          id$="claNewAgreementsInput[[item.name]]"
-          name="claNewAgreementsRadio"
-          type="radio"
-          data-name$="[[item.name]]"
-          data-url$="[[item.url]]"
-          on-click="_handleShowAgreement"
-          disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]"
-        />
-        <label id="claNewAgreementsLabel">[[item.name]]</label>
-      </span>
-      <div
-        class$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]"
-      >
-        Agreement already submitted.
-      </div>
-      <div class="agreementsUrl">[[item.description]]</div>
-    </template>
-    <div
-      id="claNewAgreement"
-      class$="[[_computeShowAgreementsClass(_showAgreements)]]"
-    >
-      <h3 class="heading-3">Review the agreement:</h3>
-      <div id="agreementsUrl" class="agreementsUrl">
-        <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
-          Please review the agreement.</a
-        >
-      </div>
-      <div
-        class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]"
-      >
-        <h3 class="heading-3">Complete the agreement:</h3>
-        <iron-input
-          bind-value="{{_agreementsText}}"
-          placeholder="Enter 'I agree' here"
-        >
-          <input id="input-agreements" placeholder="Enter 'I agree' here" />
-        </iron-input>
-        <gr-button
-          on-click="_handleSaveAgreements"
-          disabled="[[_disableAgreementsText(_agreementsText)]]"
-        >
-          Submit
-        </gr-button>
-      </div>
-    </div>
-  </main>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
index 979f859..bac8c75 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
@@ -27,8 +27,7 @@
 } from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
 import {createServerInfo} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-cla-view');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-cla-view tests', () => {
   let element: GrClaView;
@@ -131,9 +130,9 @@
     stubRestApi('getAccountAgreements').returns(
       Promise.resolve(signedAgreements)
     );
-    element = basicFixture.instantiate();
+    element = await fixture<GrClaView>(html` <gr-cla-view></gr-cla-view> `);
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders as expected with signed agreement', () => {
@@ -143,67 +142,40 @@
     assert.isFalse(
       queryAndAssert<HTMLInputElement>(agreementSections[0], 'input').disabled
     );
-    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display, 'none');
+    assert.isOk(agreementSubmittedTexts[0]);
     assert.isTrue(
       queryAndAssert<HTMLInputElement>(agreementSections[1], 'input').disabled
     );
-    assert.notEqual(
-      getComputedStyle(agreementSubmittedTexts[1]).display,
-      'none'
-    );
+    assert.isNotOk(agreementSubmittedTexts[1]);
   });
 
-  test('_disableAgreements', () => {
+  test('disableAgreements', () => {
+    element.groups = groups;
+    element.signedAgreements = signedAgreements;
     // In the auto verify group and have not yet signed agreement
-    assert.isTrue(element._disableAgreements(auth, groups, signedAgreements));
+    assert.isTrue(element.disableAgreements(auth));
     // Not in the auto verify group and have not yet signed agreement
-    assert.isFalse(element._disableAgreements(auth2, groups, signedAgreements));
+    assert.isFalse(element.disableAgreements(auth2));
     // Not in the auto verify group, have signed agreement
-    assert.isTrue(element._disableAgreements(auth3, groups, signedAgreements));
+    assert.isTrue(element.disableAgreements(auth3));
+    element.groups = undefined;
     // Make sure the undefined check works
-    assert.isFalse(
-      element._disableAgreements(auth, undefined, signedAgreements)
-    );
+    assert.isFalse(element.disableAgreements(auth));
   });
 
-  test('_hideAgreements', () => {
-    // Not in the auto verify group and have not yet signed agreement
-    assert.equal(element._hideAgreements(auth, groups, signedAgreements), '');
-    // In the auto verify group
+  test('computeHideAgreementTextbox', () => {
+    element.agreementName = auth.name;
+    element.serverConfig = config;
+    assert.isTrue(element.computeHideAgreementTextbox());
+    element.serverConfig = config2;
+    assert.isFalse(element.computeHideAgreementTextbox());
+  });
+
+  test('getAgreementsUrl', () => {
     assert.equal(
-      element._hideAgreements(auth2, groups, signedAgreements),
-      'hide'
-    );
-    // Not in the auto verify group, have signed agreement
-    assert.equal(element._hideAgreements(auth3, groups, signedAgreements), '');
-  });
-
-  test('_disableAgreementsText', () => {
-    assert.isFalse(element._disableAgreementsText('I AGREE'));
-    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
-  });
-
-  test('_computeHideAgreementClass', () => {
-    assert.equal(
-      element._computeHideAgreementClass(
-        auth.name,
-        config.auth.contributor_agreements
-      ),
-      'hideAgreementsTextBox'
-    );
-    assert.isNotOk(
-      element._computeHideAgreementClass(
-        auth.name,
-        config2.auth.contributor_agreements
-      )
-    );
-  });
-
-  test('_getAgreementsUrl', () => {
-    assert.equal(
-      element._getAgreementsUrl('http://test.org/test.html'),
+      element.getAgreementsUrl('http://test.org/test.html'),
       'http://test.org/test.html'
     );
-    assert.equal(element._getAgreementsUrl('test_cla.html'), '/test_cla.html');
+    assert.equal(element.getAgreementsUrl('test_cla.html'), '/test_cla.html');
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index 5cd1acb..94ccc16 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -14,129 +14,312 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-edit-preferences_html';
-import {customElement, property} from '@polymer/decorators';
 import {EditPreferencesInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {convertToString} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
 
-export interface GrEditPreferences {
-  $: {
-    editTabWidth: HTMLInputElement;
-    editColumns: HTMLInputElement;
-    editIndentUnit: HTMLInputElement;
-    editSyntaxHighlighting: HTMLInputElement;
-    showAutoCloseBrackets: HTMLInputElement;
-    showIndentWithTabs: HTMLInputElement;
-    showMatchBrackets: HTMLInputElement;
-    editShowLineWrapping: HTMLInputElement;
-    editShowTabs: HTMLInputElement;
-    editShowTrailingWhitespaceInput: HTMLInputElement;
-  };
-}
 @customElement('gr-edit-preferences')
-export class GrEditPreferences extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrEditPreferences extends LitElement {
+  @query('#editTabWidth') private editTabWidth?: HTMLInputElement;
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
+  @query('#editColumns') private editColumns?: HTMLInputElement;
 
-  @property({type: Object})
-  editPrefs?: EditPreferencesInfo;
+  @query('#editIndentUnit') private editIndentUnit?: HTMLInputElement;
 
-  private readonly restApiService = getAppContext().restApiService;
+  @query('#editSyntaxHighlighting')
+  private editSyntaxHighlighting?: HTMLInputElement;
 
-  loadData() {
-    return this.restApiService.getEditPreferences().then(prefs => {
-      this.editPrefs = prefs;
-    });
-  }
+  @query('#showAutoCloseBrackets')
+  private showAutoCloseBrackets?: HTMLInputElement;
 
-  _handleEditPrefsChanged() {
-    this.hasUnsavedChanges = true;
-  }
+  @query('#showIndentWithTabs') private showIndentWithTabs?: HTMLInputElement;
 
-  _handleEditTabWidthChanged() {
-    this.set('editPrefs.tab_size', Number(this.$.editTabWidth.value));
-    this._handleEditPrefsChanged();
-  }
+  @query('#showMatchBrackets') private showMatchBrackets?: HTMLInputElement;
 
-  _handleEditLineLengthChanged() {
-    this.set('editPrefs.line_length', Number(this.$.editColumns.value));
-    this._handleEditPrefsChanged();
-  }
+  @query('#editShowLineWrapping')
+  private editShowLineWrapping?: HTMLInputElement;
 
-  _handleEditIndentUnitChanged() {
-    this.set('editPrefs.indent_unit', Number(this.$.editIndentUnit.value));
-    this._handleEditPrefsChanged();
-  }
+  @query('#editShowTabs') private editShowTabs?: HTMLInputElement;
 
-  _handleEditSyntaxHighlightingChanged() {
-    this.set(
-      'editPrefs.syntax_highlighting',
-      this.$.editSyntaxHighlighting.checked
+  @query('#editShowTrailingWhitespaceInput')
+  private editShowTrailingWhitespaceInput?: HTMLInputElement;
+
+  @state() editPrefs?: EditPreferencesInfo;
+
+  @state() private originalEditPrefs?: EditPreferencesInfo;
+
+  private readonly userModel = getAppContext().userModel;
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => {
+        this.originalEditPrefs = editPreferences;
+        this.editPrefs = {...editPreferences};
+      }
     );
-    this._handleEditPrefsChanged();
   }
 
-  _handleEditShowTabsChanged() {
-    this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
-    this._handleEditPrefsChanged();
+  static override get styles() {
+    return [
+      sharedStyles,
+      menuPageStyles,
+      formStyles,
+      css`
+        :host {
+          border: none;
+          margin-bottom: var(--spacing-xxl);
+        }
+        h2 {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h2);
+          font-weight: var(--font-weight-h2);
+          line-height: var(--line-height-h2);
+        }
+      `,
+    ];
   }
 
-  _handleEditShowTrailingWhitespaceTap() {
-    this.set(
-      'editPrefs.show_whitespace_errors',
-      this.$.editShowTrailingWhitespaceInput.checked
+  override render() {
+    return html`
+      <h2
+        id="EditPreferences"
+        class=${this.hasUnsavedChanges() ? 'edited' : ''}
+      >
+        Edit Preferences
+      </h2>
+      <fieldset id="editPreferences">
+        <div id="editPreferences" class="gr-form-styles">
+          <section>
+            <label for="editTabWidth" class="title">Tab width</label>
+            <span class="value">
+              <iron-input
+                .allowedPattern=${'[0-9]'}
+                .bindValue=${convertToString(this.editPrefs?.tab_size)}
+                @change=${this.handleEditTabWidthChanged}
+              >
+                <input id="editTabWidth" type="number" />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <label for="editColumns" class="title">Columns</label>
+            <span class="value">
+              <iron-input
+                .allowedPattern=${'[0-9]'}
+                .bindValue=${convertToString(this.editPrefs?.line_length)}
+                @change=${this.handleEditLineLengthChanged}
+              >
+                <input id="editColumns" type="number" />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <label for="editIndentUnit" class="title">Indent unit</label>
+            <span class="value">
+              <iron-input
+                .allowedPattern=${'[0-9]'}
+                .bindValue=${convertToString(this.editPrefs?.indent_unit)}
+                @change=${this.handleEditIndentUnitChanged}
+              >
+                <input id="editIndentUnit" type="number" />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <label for="editSyntaxHighlighting" class="title"
+              >Syntax highlighting</label
+            >
+            <span class="value">
+              <input
+                id="editSyntaxHighlighting"
+                type="checkbox"
+                ?checked=${this.editPrefs?.syntax_highlighting}
+                @change=${this.handleEditSyntaxHighlightingChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="editShowTabs" class="title">Show tabs</label>
+            <span class="value">
+              <input
+                id="editShowTabs"
+                type="checkbox"
+                ?checked=${this.editPrefs?.show_tabs}
+                @change=${this.handleEditShowTabsChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showTrailingWhitespaceInput" class="title"
+              >Show trailing whitespace</label
+            >
+            <span class="value">
+              <input
+                id="editShowTrailingWhitespaceInput"
+                type="checkbox"
+                ?checked=${this.editPrefs?.show_whitespace_errors}
+                @change=${this.handleEditShowTrailingWhitespaceTap}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showMatchBrackets" class="title">Match brackets</label>
+            <span class="value">
+              <input
+                id="showMatchBrackets"
+                type="checkbox"
+                ?checked=${this.editPrefs?.match_brackets}
+                @change=${this.handleMatchBracketsChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="editShowLineWrapping" class="title"
+              >Line wrapping</label
+            >
+            <span class="value">
+              <input
+                id="editShowLineWrapping"
+                type="checkbox"
+                ?checked=${this.editPrefs?.line_wrapping}
+                @change=${this.handleEditLineWrappingChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showIndentWithTabs" class="title"
+              >Indent with tabs</label
+            >
+            <span class="value">
+              <input
+                id="showIndentWithTabs"
+                type="checkbox"
+                ?checked=${this.editPrefs?.indent_with_tabs}
+                @change=${this.handleIndentWithTabsChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showAutoCloseBrackets" class="title"
+              >Auto close brackets</label
+            >
+            <span class="value">
+              <input
+                id="showAutoCloseBrackets"
+                type="checkbox"
+                ?checked=${this.editPrefs?.auto_close_brackets}
+                @change=${this.handleAutoCloseBracketsChanged}
+              />
+            </span>
+          </section>
+        </div>
+        <gr-button
+          id="saveEditPrefs"
+          @click=${this.handleSaveEditPreferences}
+          ?disabled=${!this.hasUnsavedChanges()}
+          >Save changes</gr-button
+        >
+      </fieldset>
+    `;
+  }
+
+  private readonly handleEditTabWidthChanged = () => {
+    this.editPrefs!.tab_size = Number(this.editTabWidth!.value);
+    this.requestUpdate();
+  };
+
+  private readonly handleEditLineLengthChanged = () => {
+    this.editPrefs!.line_length = Number(this.editColumns!.value);
+    this.requestUpdate();
+  };
+
+  private readonly handleEditIndentUnitChanged = () => {
+    this.editPrefs!.indent_unit = Number(this.editIndentUnit!.value);
+    this.requestUpdate();
+  };
+
+  private readonly handleEditSyntaxHighlightingChanged = () => {
+    this.editPrefs!.syntax_highlighting = this.editSyntaxHighlighting!.checked;
+    this.requestUpdate();
+  };
+
+  // private but used in test
+  readonly handleEditShowTabsChanged = () => {
+    this.editPrefs!.show_tabs = this.editShowTabs!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleEditShowTrailingWhitespaceTap = () => {
+    this.editPrefs!.show_whitespace_errors =
+      this.editShowTrailingWhitespaceInput!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleMatchBracketsChanged = () => {
+    this.editPrefs!.match_brackets = this.showMatchBrackets!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleEditLineWrappingChanged = () => {
+    this.editPrefs!.line_wrapping = this.editShowLineWrapping!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleIndentWithTabsChanged = () => {
+    this.editPrefs!.indent_with_tabs = this.showIndentWithTabs!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleAutoCloseBracketsChanged = () => {
+    this.editPrefs!.auto_close_brackets = this.showAutoCloseBrackets!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleSaveEditPreferences = () => {
+    this.save();
+  };
+
+  // private but used in test
+  hasUnsavedChanges() {
+    // We have to wrap boolean values in Boolean() to ensure undefined values
+    // use false rather than undefined.
+    return (
+      this.originalEditPrefs?.tab_size !== this.editPrefs?.tab_size ||
+      this.originalEditPrefs?.line_length !== this.editPrefs?.line_length ||
+      this.originalEditPrefs?.indent_unit !== this.editPrefs?.indent_unit ||
+      Boolean(this.originalEditPrefs?.syntax_highlighting) !==
+        Boolean(this.editPrefs?.syntax_highlighting) ||
+      Boolean(this.originalEditPrefs?.show_tabs) !==
+        Boolean(this.editPrefs?.show_tabs) ||
+      Boolean(this.originalEditPrefs?.show_whitespace_errors) !==
+        Boolean(this.editPrefs?.show_whitespace_errors) ||
+      Boolean(this.originalEditPrefs?.match_brackets) !==
+        Boolean(this.editPrefs?.match_brackets) ||
+      Boolean(this.originalEditPrefs?.line_wrapping) !==
+        Boolean(this.editPrefs?.line_wrapping) ||
+      Boolean(this.originalEditPrefs?.indent_with_tabs) !==
+        Boolean(this.editPrefs?.indent_with_tabs) ||
+      Boolean(this.originalEditPrefs?.auto_close_brackets) !==
+        Boolean(this.editPrefs?.auto_close_brackets)
     );
-    this._handleEditPrefsChanged();
   }
 
-  _handleMatchBracketsChanged() {
-    this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleEditLineWrappingChanged() {
-    this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleIndentWithTabsChanged() {
-    this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleAutoCloseBracketsChanged() {
-    this.set(
-      'editPrefs.auto_close_brackets',
-      this.$.showAutoCloseBrackets.checked
-    );
-    this._handleEditPrefsChanged();
-  }
-
-  save() {
-    if (!this.editPrefs)
-      return Promise.reject(new Error('Missing edit preferences'));
-    return this.restApiService.saveEditPreferences(this.editPrefs).then(() => {
-      this.hasUnsavedChanges = false;
-    });
-  }
-
-  /**
-   * bind-value has type string so we have to convert
-   * anything inputed to string.
-   *
-   * This is so typescript checker doesn't fail.
-   */
-  _convertToString(key?: number) {
-    return key !== undefined ? String(key) : '';
+  async save() {
+    if (!this.editPrefs) return;
+    await this.userModel.updateEditPreference(this.editPrefs);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
deleted file mode 100644
index abd925f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="editPreferences" class="gr-form-styles">
-    <section>
-      <label for="editTabWidth" class="title">Tab width</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(editPrefs.tab_size)]]"
-          on-change="_handleEditTabWidthChanged"
-        >
-          <input id="editTabWidth" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="editColumns" class="title">Columns</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(editPrefs.line_length)]]"
-          on-change="_handleEditLineLengthChanged"
-        >
-          <input id="editColumns" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="editIndentUnit" class="title">Indent unit</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(editPrefs.indent_unit)]]"
-          on-change="_handleEditIndentUnitChanged"
-        >
-          <input id="indentUnit" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="editSyntaxHighlighting" class="title"
-        >Syntax highlighting</label
-      >
-      <span class="value">
-        <input
-          id="editSyntaxHighlighting"
-          type="checkbox"
-          checked$="[[editPrefs.syntax_highlighting]]"
-          on-change="_handleEditSyntaxHighlightingChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="editShowTabs" class="title">Show tabs</label>
-      <span class="value">
-        <input
-          id="editShowTabs"
-          type="checkbox"
-          checked$="[[editPrefs.show_tabs]]"
-          on-change="_handleEditShowTabsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showTrailingWhitespaceInput" class="title"
-        >Show trailing whitespace</label
-      >
-      <span class="value">
-        <input
-          id="editShowTrailingWhitespaceInput"
-          type="checkbox"
-          checked$="[[editPrefs.show_whitespace_errors]]"
-          on-change="_handleEditShowTrailingWhitespaceTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showMatchBrackets" class="title">Match brackets</label>
-      <span class="value">
-        <input
-          id="showMatchBrackets"
-          type="checkbox"
-          checked$="[[editPrefs.match_brackets]]"
-          on-change="_handleMatchBracketsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="editShowLineWrapping" class="title">Line wrapping</label>
-      <span class="value">
-        <input
-          id="editShowLineWrapping"
-          type="checkbox"
-          checked$="[[editPrefs.line_wrapping]]"
-          on-change="_handleEditLineWrappingChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showIndentWithTabs" class="title">Indent with tabs</label>
-      <span class="value">
-        <input
-          id="showIndentWithTabs"
-          type="checkbox"
-          checked$="[[editPrefs.indent_with_tabs]]"
-          on-change="_handleIndentWithTabsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showAutoCloseBrackets" class="title"
-        >Auto close brackets</label
-      >
-      <span class="value">
-        <input
-          id="showAutoCloseBrackets"
-          type="checkbox"
-          checked$="[[editPrefs.auto_close_brackets]]"
-          on-change="_handleAutoCloseBracketsChanged"
-        />
-      </span>
-    </section>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
index bfdcaa0..894aae2 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
@@ -17,20 +17,20 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-edit-preferences';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAll, stubRestApi} from '../../../test/test-utils';
 import {GrEditPreferences} from './gr-edit-preferences';
-import {EditPreferencesInfo} from '../../../types/common';
+import {EditPreferencesInfo, ParsedJSON} from '../../../types/common';
 import {IronInputElement} from '@polymer/iron-input';
+import {createDefaultEditPrefs} from '../../../constants/constants';
 
 const basicFixture = fixtureFromElement('gr-edit-preferences');
 
 suite('gr-edit-preferences tests', () => {
   let element: GrEditPreferences;
-
   let editPreferences: EditPreferencesInfo;
 
   function valueOf(title: string, id: string): Element {
-    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    const sections = queryAll(element, `#${id} section`) ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -43,31 +43,13 @@
   }
 
   setup(async () => {
-    editPreferences = {
-      auto_close_brackets: false,
-      cursor_blink_rate: 0,
-      hide_line_numbers: false,
-      hide_top_menu: false,
-      indent_unit: 2,
-      indent_with_tabs: false,
-      key_map_type: 'DEFAULT',
-      line_length: 100,
-      line_wrapping: false,
-      match_brackets: true,
-      show_base: false,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
+    editPreferences = createDefaultEditPrefs();
 
     stubRestApi('getEditPreferences').returns(Promise.resolve(editPreferences));
 
     element = basicFixture.instantiate();
 
-    await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -108,18 +90,29 @@
       .firstElementChild as HTMLInputElement;
     assert.equal(autoCloseInput.checked, editPreferences.auto_close_brackets);
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(element.hasUnsavedChanges());
   });
 
   test('save changes', async () => {
+    assert.isTrue(element.editPrefs?.show_tabs);
+
     const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
       .firstElementChild as HTMLInputElement;
     showTabsCheckbox.checked = false;
-    element._handleEditShowTabsChanged();
+    element.handleEditShowTabsChanged();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(element.hasUnsavedChanges());
+
+    const getResponseObjStub = stubRestApi('getResponseObject').returns(
+      Promise.resolve(element.editPrefs! as unknown as ParsedJSON)
+    );
 
     await element.save();
-    assert.isFalse(element.hasUnsavedChanges);
+
+    assert.isTrue(getResponseObjStub.called);
+
+    assert.isFalse(element.editPrefs?.show_tabs);
+
+    assert.isFalse(element.hasUnsavedChanges());
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index afd04b4..40c0690 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -16,73 +16,147 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-email-editor_html';
-import {customElement, property} from '@polymer/decorators';
 import {EmailInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 @customElement('gr-email-editor')
-export class GrEmailEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrEmailEditor extends LitElement {
+  @property({type: Boolean}) hasUnsavedChanges = false;
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
+  /* private but used in test */
+  @state() emails: EmailInfo[] = [];
 
-  @property({type: Array})
-  _emails: EmailInfo[] = [];
+  /* private but used in test */
+  @state() emailsToRemove: EmailInfo[] = [];
 
-  @property({type: Array})
-  _emailsToRemove: EmailInfo[] = [];
-
-  @property({type: String})
-  _newPreferred: string | null = null;
+  /* private but used in test */
+  @state() newPreferred = '';
 
   readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      th {
+        color: var(--deemphasized-text-color);
+        text-align: left;
+      }
+      #emailTable .emailColumn {
+        min-width: 32.5em;
+        width: auto;
+      }
+      #emailTable .preferredHeader {
+        text-align: center;
+        width: 6em;
+      }
+      #emailTable .preferredControl {
+        cursor: pointer;
+        height: auto;
+        text-align: center;
+      }
+      #emailTable .preferredControl .preferredRadio {
+        height: auto;
+      }
+      .preferredControl:hover {
+        outline: 1px solid var(--border-color);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+      <table id="emailTable">
+        <thead>
+          <tr>
+            <th class="emailColumn">Email</th>
+            <th class="preferredHeader">Preferred</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${this.emails.map((email, index) => this.renderEmail(email, index))}
+        </tbody>
+      </table>
+    </div>`;
+  }
+
+  private renderEmail(email: EmailInfo, index: number) {
+    return html`<tr>
+      <td class="emailColumn">${email.email}</td>
+      <td class="preferredControl" @click=${this.handlePreferredControlClick}>
+        <iron-input
+          class="preferredRadio"
+          @change=${this.handlePreferredChange}
+          .bindValue=${email.email}
+        >
+          <input
+            class="preferredRadio"
+            type="radio"
+            @change=${this.handlePreferredChange}
+            name="preferred"
+            ?checked=${email.preferred}
+          />
+        </iron-input>
+      </td>
+      <td>
+        <gr-button
+          data-index=${index}
+          @click=${this.handleDeleteButton}
+          ?disabled=${this.checkPreferred(email.preferred)}
+          class="remove-button"
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
   loadData() {
     return this.restApiService.getAccountEmails().then(emails => {
-      this._emails = emails ?? [];
+      this.emails = emails ?? [];
     });
   }
 
   save() {
     const promises: Promise<unknown>[] = [];
 
-    for (const emailObj of this._emailsToRemove) {
+    for (const emailObj of this.emailsToRemove) {
       promises.push(this.restApiService.deleteAccountEmail(emailObj.email));
     }
 
-    if (this._newPreferred) {
+    if (this.newPreferred) {
       promises.push(
-        this.restApiService.setPreferredAccountEmail(this._newPreferred)
+        this.restApiService.setPreferredAccountEmail(this.newPreferred)
       );
     }
 
     return Promise.all(promises).then(() => {
-      this._emailsToRemove = [];
-      this._newPreferred = null;
-      this.hasUnsavedChanges = false;
+      this.emailsToRemove = [];
+      this.newPreferred = '';
+      this.setHasUnsavedChanges(false);
     });
   }
 
-  _handleDeleteButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
+  private handleDeleteButton(e: Event) {
+    const target = e.target;
     if (!(target instanceof Element)) return;
     const indexStr = target.getAttribute('data-index');
     if (indexStr === null) return;
     const index = Number(indexStr);
-    const email = this._emails[index];
-    this.push('_emailsToRemove', email);
-    this.splice('_emails', index, 1);
-    this.hasUnsavedChanges = true;
+    const email = this.emails[index];
+    this.emailsToRemove = [...this.emailsToRemove, email];
+    this.emails.splice(index, 1);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handlePreferredControlClick(e: Event) {
+  private handlePreferredControlClick(e: Event) {
     if (
       e.target instanceof HTMLElement &&
       e.target.classList.contains('preferredControl') &&
@@ -92,26 +166,36 @@
     }
   }
 
-  _handlePreferredChange(e: Event) {
+  private handlePreferredChange(e: Event) {
     if (!(e.target instanceof HTMLInputElement)) return;
     const preferred = e.target.value;
-    for (let i = 0; i < this._emails.length; i++) {
-      if (preferred === this._emails[i].email) {
-        this.set(['_emails', i, 'preferred'], true);
-        this._newPreferred = preferred;
-        this.hasUnsavedChanges = true;
-      } else if (this._emails[i].preferred) {
-        this.set(['_emails', i, 'preferred'], false);
+    for (let i = 0; i < this.emails.length; i++) {
+      if (preferred === this.emails[i].email) {
+        this.emails[i].preferred = true;
+        this.requestUpdate();
+        this.newPreferred = preferred;
+        this.setHasUnsavedChanges(true);
+      } else if (this.emails[i].preferred) {
+        this.emails[i].preferred = false;
+        this.requestUpdate();
       }
     }
   }
 
-  _checkPreferred(preferred?: boolean) {
+  private checkPreferred(preferred?: boolean) {
     return preferred ?? false;
   }
+
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
+  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'has-unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-email-editor': GrEmailEditor;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
deleted file mode 100644
index 666afb7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    th {
-      color: var(--deemphasized-text-color);
-      text-align: left;
-    }
-    #emailTable .emailColumn {
-      min-width: 32.5em;
-      width: auto;
-    }
-    #emailTable .preferredHeader {
-      text-align: center;
-      width: 6em;
-    }
-    #emailTable .preferredControl {
-      cursor: pointer;
-      height: auto;
-      text-align: center;
-    }
-    #emailTable .preferredControl .preferredRadio {
-      height: auto;
-    }
-    .preferredControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="emailTable">
-      <thead>
-        <tr>
-          <th class="emailColumn">Email</th>
-          <th class="preferredHeader">Preferred</th>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_emails]]">
-          <tr>
-            <td class="emailColumn">[[item.email]]</td>
-            <td
-              class="preferredControl"
-              on-click="_handlePreferredControlClick"
-            >
-              <iron-input
-                class="preferredRadio"
-                type="radio"
-                on-change="_handlePreferredChange"
-                name="preferred"
-                bind-value="[[item.email]]"
-                checked$="[[item.preferred]]"
-              >
-                <input
-                  class="preferredRadio"
-                  type="radio"
-                  on-change="_handlePreferredChange"
-                  name="preferred"
-                  checked$="[[item.preferred]]"
-                />
-              </iron-input>
-            </td>
-            <td>
-              <gr-button
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                disabled="[[_checkPreferred(item.preferred)]]"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index e4c1584..8ac101d 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -19,8 +19,7 @@
 import './gr-email-editor';
 import {GrEmailEditor} from './gr-email-editor';
 import {spyRestApi, stubRestApi} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-email-editor');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-email-editor tests', () => {
   let element: GrEmailEditor;
@@ -34,16 +33,108 @@
 
     stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrEmailEditor>(
+      html`<gr-email-editor></gr-email-editor>`
+    );
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="gr-form-styles">
+      <table id="emailTable">
+        <thead>
+          <tr>
+            <th class="emailColumn">Email</th>
+            <th class="preferredHeader">Preferred</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td class="emailColumn">email@one.com</td>
+            <td class="preferredControl">
+              <iron-input class="preferredRadio">
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@one.com"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                aria-disabled="false"
+                class="remove-button"
+                data-index="0"
+                role="button"
+                tabindex="0"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+          <tr>
+            <td class="emailColumn">email@two.com</td>
+            <td class="preferredControl">
+              <iron-input class="preferredRadio">
+                <input
+                  checked=""
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@two.com"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                aria-disabled="true"
+                class="remove-button"
+                data-index="1"
+                disabled=""
+                role="button"
+                tabindex="-1"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+          <tr>
+            <td class="emailColumn">email@three.com</td>
+            <td class="preferredControl">
+              <iron-input class="preferredRadio">
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@three.com"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                aria-disabled="false"
+                class="remove-button"
+                data-index="2"
+                role="button"
+                tabindex="0"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>`);
   });
 
   test('renders', () => {
     const rows = element
       .shadowRoot!.querySelector('table')!
-      .querySelectorAll('tbody tr') as NodeListOf<HTMLTableRowElement>;
+      .querySelectorAll('tbody tr');
 
     assert.equal(rows.length, 3);
 
@@ -66,28 +157,27 @@
   });
 
   test('edit preferred', () => {
-    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
     const radios = element
       .shadowRoot!.querySelector('table')!
-      .querySelectorAll('input[type=radio]') as NodeListOf<HTMLInputElement>;
+      .querySelectorAll<HTMLInputElement>('input[type=radio]');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
     assert.isNotOk(radios[0].checked);
     assert.isOk(radios[1].checked);
-    assert.isFalse(preferredChangedSpy.called);
+    assert.isUndefined(element.emails[0].preferred);
 
     radios[0].click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.isOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
     assert.isOk(radios[0].checked);
     assert.isNotOk(radios[1].checked);
-    assert.isTrue(preferredChangedSpy.called);
+    assert.isTrue(element.emails[0].preferred);
   });
 
   test('delete email', () => {
@@ -96,18 +186,18 @@
       .querySelectorAll('gr-button');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
 
     buttons[2].click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emails.length, 2);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 1);
+    assert.equal(element.emails.length, 2);
 
-    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+    assert.equal(element.emailsToRemove[0].email, 'email@three.com');
   });
 
   test('save changes', async () => {
@@ -119,19 +209,19 @@
       .querySelectorAll('tbody tr');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
 
     // Delete the first email and set the last as preferred.
     rows[0].querySelector('gr-button')!.click();
-    (rows[2].querySelector('input[type=radio]')! as HTMLInputElement).click();
+    rows[2].querySelector<HTMLInputElement>('input[type=radio]')!.click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.equal(element._newPreferred, 'email@three.com');
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emailsToRemove[0].email, 'email@one.com');
-    assert.equal(element._emails.length, 2);
+    assert.equal(element.newPreferred, 'email@three.com');
+    assert.equal(element.emailsToRemove.length, 1);
+    assert.equal(element.emailsToRemove[0].email, 'email@one.com');
+    assert.equal(element.emails.length, 2);
 
     await element.save();
     assert.equal(deleteEmailSpy.callCount, 1);
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index ed07fd6..3c54c59 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -19,24 +19,18 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-gpg-editor_html';
-import {customElement, property} from '@polymer/decorators';
 import {GpgKeyInfo, GpgKeyId} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {getAppContext} from '../../../services/app-context';
-
-export interface GrGpgEditor {
-  $: {
-    viewKeyOverlay: GrOverlay;
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-  };
-}
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,35 +38,157 @@
   }
 }
 @customElement('gr-gpg-editor')
-export class GrGpgEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrGpgEditor extends LitElement {
+  @query('#viewKeyOverlay') viewKeyOverlay?: GrOverlay;
 
-  @property({type: Boolean, notify: true})
+  @query('#addButton') addButton?: GrButton;
+
+  @query('#newKey') newKeyTextarea?: IronAutogrowTextareaElement;
+
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
-  @property({type: Array})
-  _keys: GpgKeyInfo[] = [];
+  // private but used in test
+  @state() keys: GpgKeyInfo[] = [];
 
-  @property({type: Object})
-  _keyToView?: GpgKeyInfo;
+  // private but used in test
+  @state() keyToView?: GpgKeyInfo;
 
-  @property({type: String})
-  _newKey = '';
+  // private but used in test
+  @state() newKey = '';
 
-  @property({type: Array})
-  _keysToRemove: GpgKeyInfo[] = [];
+  // private but used in test
+  @state() keysToRemove: GpgKeyInfo[] = [];
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      .keyHeader {
+        width: 9em;
+      }
+      .userIdHeader {
+        width: 15em;
+      }
+      #viewKeyOverlay {
+        padding: var(--spacing-xxl);
+        width: 50em;
+      }
+      .closeButton {
+        bottom: 2em;
+        position: absolute;
+        right: 2em;
+      }
+      #existing {
+        margin-bottom: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="idColumn">ID</th>
+                <th class="fingerPrintColumn">Fingerprint</th>
+                <th class="userIdHeader">User IDs</th>
+                <th class="keyHeader">Public Key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.keys.map((key, index) => this.renderKey(key, index))}
+            </tbody>
+          </table>
+          <gr-overlay id="viewKeyOverlay" with-backdrop="">
+            <fieldset>
+              <section>
+                <span class="title">Status</span>
+                <span class="value">${this.keyToView?.status}</span>
+              </section>
+              <section>
+                <span class="title">Key</span>
+                <span class="value">${this.keyToView?.key}</span>
+              </section>
+            </fieldset>
+            <gr-button
+              class="closeButton"
+              @click=${() => {
+                this.viewKeyOverlay?.close();
+              }}
+              >Close</gr-button
+            >
+          </gr-overlay>
+          <gr-button @click=${this.save} ?disabled=${!this.hasUnsavedChanges}
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">New GPG key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                .bindValue=${this.newKey}
+                @bind-value-changed=${(e: BindValueChangeEvent) =>
+                  this.handleNewKeyChanged(e)}
+                placeholder="New GPG Key"
+              ></iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            id="addButton"
+            ?disabled=${!this.newKey?.length}
+            @click=${this.handleAddKey}
+            >Add new GPG key</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderKey(key: GpgKeyInfo, index: number) {
+    return html`<tr>
+      <td class="idColumn">${key.id}</td>
+      <td class="fingerPrintColumn">${key.fingerprint}</td>
+      <td class="userIdHeader">${key.user_ids?.map(id => html`${id}`)}</td>
+      <td class="keyHeader">
+        <gr-button @click=${() => this.showKey(key)} link=""
+          >Click to View</gr-button
+        >
+      </td>
+      <td>
+        <gr-copy-clipboard
+          hasTooltip
+          buttonTitle="Copy GPG public key to clipboard"
+          hideInput
+          .text=${key.key}
+        >
+        </gr-copy-clipboard>
+      </td>
+      <td>
+        <gr-button @click=${() => this.handleDeleteKey(index)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  // private but used in test
   loadData() {
-    this._keys = [];
+    this.keys = [];
     return this.restApiService.getAccountGPGKeys().then(keys => {
       if (!keys) {
         return;
       }
-      this._keys = Object.keys(keys).map(key => {
+      this.keys = Object.keys(keys).map(key => {
         const gpgKey = keys[key];
         gpgKey.id = key as GpgKeyId;
         return gpgKey;
@@ -80,53 +196,58 @@
     });
   }
 
+  // private but used in test
   save() {
-    const promises = this._keysToRemove.map(key =>
+    const promises = this.keysToRemove.map(key =>
       this.restApiService.deleteAccountGPGKey(key.id!)
     );
 
     return Promise.all(promises).then(() => {
-      this._keysToRemove = [];
-      this.hasUnsavedChanges = false;
+      this.keysToRemove = [];
+      this.setHasUnsavedChanges(false);
     });
   }
 
-  _showKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as Element;
-    const index = Number(el.getAttribute('data-index')!);
-    this._keyToView = this._keys[index];
-    this.$.viewKeyOverlay.open();
+  private showKey(key: GpgKeyInfo) {
+    this.keyToView = key;
+    this.viewKeyOverlay?.open();
   }
 
-  _closeOverlay() {
-    this.$.viewKeyOverlay.close();
+  private handleNewKeyChanged(e: BindValueChangeEvent) {
+    this.newKey = e.detail.value;
   }
 
-  _handleDeleteKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as Element;
-    const index = Number(el.getAttribute('data-index')!);
-    this.push('_keysToRemove', this._keys[index]);
-    this.splice('_keys', index, 1);
-    this.hasUnsavedChanges = true;
+  private handleDeleteKey(index: number) {
+    this.keysToRemove.push(this.keys[index]);
+    this.keys.splice(index, 1);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleAddKey() {
-    this.$.addButton.disabled = true;
-    this.$.newKey.disabled = true;
+  // private but used in test
+  handleAddKey() {
+    assertIsDefined(this.newKeyTextarea);
+    assertIsDefined(this.addButton);
+    this.addButton.disabled = true;
+    this.newKeyTextarea.disabled = true;
     return this.restApiService
-      .addAccountGPGKey({add: [this._newKey.trim()]})
+      .addAccountGPGKey({add: [this.newKey.trim()]})
       .then(() => {
-        this.$.newKey.disabled = false;
-        this._newKey = '';
+        assertIsDefined(this.newKeyTextarea);
+        this.newKeyTextarea.disabled = false;
+        this.newKey = '';
         this.loadData();
       })
       .catch(() => {
-        this.$.addButton.disabled = false;
-        this.$.newKey.disabled = false;
+        assertIsDefined(this.newKeyTextarea);
+        assertIsDefined(this.addButton);
+        this.addButton.disabled = false;
+        this.newKeyTextarea.disabled = false;
       });
   }
 
-  _computeAddButtonDisabled(newKey: string) {
-    return !newKey.length;
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
deleted file mode 100644
index f4641c2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .keyHeader {
-      width: 9em;
-    }
-    .userIdHeader {
-      width: 15em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="idColumn">ID</th>
-            <th class="fingerPrintColumn">Fingerprint</th>
-            <th class="userIdHeader">User IDs</th>
-            <th class="keyHeader">Public Key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="idColumn">[[key.id]]</td>
-              <td class="fingerPrintColumn">[[key.fingerprint]]</td>
-              <td class="userIdHeader">
-                <template is="dom-repeat" items="[[key.user_ids]]">
-                  [[item]]
-                </template>
-              </td>
-              <td class="keyHeader">
-                <gr-button on-click="_showKey" data-index$="[[index]]" link=""
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  hasTooltip=""
-                  buttonTitle="Copy GPG public key to clipboard"
-                  hideInput=""
-                  text="[[key.key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button data-index$="[[index]]" on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Status</span>
-            <span class="value">[[_keyToView.status]]</span>
-          </section>
-          <section>
-            <span class="title">Key</span>
-            <span class="value">[[_keyToView.key]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New GPG key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New GPG Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new GPG key</gr-button
-      >
-    </fieldset>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
index c31b40d..0f775b80 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -31,7 +31,6 @@
   OpenPgpUserIds,
 } from '../../../api/rest-api';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-gpg-editor');
 
@@ -41,9 +40,9 @@
 
   setup(async () => {
     const fingerprint1 =
-      '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
+      '0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
     const fingerprint2 =
-      '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
+      '0196 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
     keys = {
       AFC8A49B: {
         fingerprint: fingerprint1,
@@ -70,7 +69,138 @@
     element = basicFixture.instantiate();
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="gr-form-styles">
+      <fieldset id="existing">
+        <table>
+          <thead>
+            <tr>
+              <th class="idColumn">ID</th>
+              <th class="fingerPrintColumn">Fingerprint</th>
+              <th class="userIdHeader">User IDs</th>
+              <th class="keyHeader">Public Key</th>
+              <th></th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td class="idColumn">AFC8A49B</td>
+              <td class="fingerPrintColumn">
+                0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B
+              </td>
+              <td class="userIdHeader">John Doe john.doe@example.com</td>
+              <td class="keyHeader">
+                <gr-button
+                  aria-disabled="false"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Click to View
+                </gr-button>
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  buttontitle="Copy GPG public key to clipboard"
+                  hastooltip=""
+                  hideinput=""
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+            <tr>
+              <td class="idColumn">AED9B59C</td>
+              <td class="fingerPrintColumn">
+                0196 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B
+              </td>
+              <td class="userIdHeader">Gerrit gerrit@example.com</td>
+              <td class="keyHeader">
+                <gr-button
+                  aria-disabled="false"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Click to View
+                </gr-button>
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  buttontitle="Copy GPG public key to clipboard"
+                  hastooltip=""
+                  hideinput=""
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <gr-overlay
+          aria-hidden="true"
+          id="viewKeyOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <fieldset>
+            <section>
+              <span class="title"> Status </span> <span class="value"> </span>
+            </section>
+            <section>
+              <span class="title"> Key </span> <span class="value"> </span>
+            </section>
+          </fieldset>
+          <gr-button
+            aria-disabled="false"
+            class="closeButton"
+            role="button"
+            tabindex="0"
+          >
+            Close
+          </gr-button>
+        </gr-overlay>
+        <gr-button aria-disabled="true" disabled="" role="button" tabindex="-1">
+          Save changes
+        </gr-button>
+      </fieldset>
+      <fieldset>
+        <section>
+          <span class="title"> New GPG key </span>
+          <span class="value">
+            <iron-autogrow-textarea
+              aria-disabled="false"
+              autocomplete="on"
+              id="newKey"
+              placeholder="New GPG Key"
+            >
+            </iron-autogrow-textarea>
+          </span>
+        </section>
+        <gr-button
+          aria-disabled="true"
+          disabled=""
+          id="addButton"
+          role="button"
+          tabindex="-1"
+        >
+          Add new GPG key
+        </gr-button>
+      </fieldset>
+    </div> `);
   });
 
   test('renders', () => {
@@ -92,7 +222,7 @@
       Promise.resolve(new Response())
     );
 
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
 
     // Get the delete button for the last row.
@@ -101,23 +231,23 @@
       'tbody tr:last-of-type td:nth-child(6) gr-button'
     );
 
-    MockInteractions.tap(button);
+    button.click();
 
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
+    assert.equal(element.keys.length, 1);
+    assert.equal(element.keysToRemove.length, 1);
+    assert.equal(element.keysToRemove[0], lastKey);
     assert.isTrue(element.hasUnsavedChanges);
     assert.isFalse(saveStub.called);
 
     await element.save();
     assert.isTrue(saveStub.called);
     assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
   });
 
   test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+    const openSpy = sinon.spy(element.viewKeyOverlay!, 'open');
 
     // Get the show button for the last row.
     const button = queryAndAssert<GrButton>(
@@ -125,16 +255,14 @@
       'tbody tr:last-of-type td:nth-child(4) gr-button'
     );
 
-    MockInteractions.tap(button);
-
-    assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
+    button.click();
+    assert.equal(element.keyToView, keys[Object.keys(keys)[1]]);
     assert.isTrue(openSpy.called);
   });
 
   test('add key', async () => {
     const newKeyString =
-      '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-      '\nVersion: BCPG v1.52\n\t<key 3>';
+      '-----BEGIN PGP PUBLIC KEY BLOCK-----' + ' Version: BCPG v1.52 \t<key 3>';
     const newKeyObject = {
       ADE8A59B: {
         fingerprint:
@@ -150,21 +278,22 @@
       Promise.resolve(newKeyObject)
     );
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
+    await element.updateComplete;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
+    element.handleAddKey().then(() => {
+      assert.isTrue(element.addButton!.disabled);
+      assert.isFalse(element.newKeyTextarea!.disabled);
+      assert.equal(element.keys.length, 2);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton!.disabled);
+    assert.isTrue(element.newKeyTextarea!.disabled);
 
     assert.isTrue(addStub.called);
     assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
@@ -178,21 +307,22 @@
       Promise.reject(new Error('error'))
     );
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
+    await element.updateComplete;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
+    element.handleAddKey().then(() => {
+      assert.isFalse(element.addButton!.disabled);
+      assert.isFalse(element.newKeyTextarea!.disabled);
+      assert.equal(element.keys.length, 2);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton!.disabled);
+    assert.isTrue(element.newKeyTextarea!.disabled);
 
     assert.isTrue(addStub.called);
     assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
index 2db8752..2b8a1e9 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
@@ -80,7 +80,7 @@
             return html`
               <tr>
                 <td class="nameColumn">
-                  <a href="${href}"> ${group.name} </a>
+                  <a href=${href}> ${group.name} </a>
                 </td>
                 <td>${group.description}</td>
                 <td class="visibleCell">
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index ebe30a3..73fc55f 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -125,7 +125,7 @@
           >
         </div>
         <span ?hidden=${!this._passwordUrl}>
-          <a href="${this._passwordUrl!}" target="_blank" rel="noopener">
+          <a href=${this._passwordUrl!} target="_blank" rel="noopener">
             Obtain password</a
           >
           (opens in a new tab)
@@ -144,7 +144,7 @@
               hasTooltip=""
               buttonTitle="Copy password to clipboard"
               hideInput=""
-              .text="${this._generatedPassword}"
+              .text=${this._generatedPassword}
             >
             </gr-copy-clipboard>
           </section>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 9e1aaa9..d8a7579 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -14,105 +14,185 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
 import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-identities_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {PolymerDomRepeatEvent} from '../../../types/types';
 import {getAppContext} from '../../../services/app-context';
 import {AuthType} from '../../../constants/constants';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {classMap} from 'lit/directives/class-map';
+import {when} from 'lit/directives/when.js';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const AUTH = [AuthType.OPENID, AuthType.OAUTH];
 
-export interface GrIdentities {
-  $: {
-    overlay: GrOverlay;
-  };
-}
-
 @customElement('gr-identities')
-export class GrIdentities extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrIdentities extends LitElement {
+  @query('#overlay') overlay?: GrOverlay;
 
-  @property({type: Array})
-  _identities: AccountExternalIdInfo[] = [];
+  @state() private identities: AccountExternalIdInfo[] = [];
 
-  @property({type: String})
-  _idName?: string;
+  // temporary var for communicating with the confirmation dialog
+  // private but used in test
+  @state() idName?: string;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
+  @property({type: Object}) serverConfig?: ServerInfo;
 
-  @property({
-    type: Boolean,
-    computed: '_computeShowLinkAnotherIdentity(serverConfig)',
-  })
-  _showLinkAnotherIdentity?: boolean;
+  @state() showLinkAnotherIdentity = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      tr th.emailAddressHeader,
+      tr th.identityHeader {
+        width: 15em;
+        padding: 0 10px;
+      }
+      tr td.statusColumn,
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        word-break: break-word;
+      }
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        padding: 4px 10px;
+        width: 15em;
+      }
+      .deleteButton {
+        float: right;
+      }
+      .deleteButton:not(.show) {
+        display: none;
+      }
+      .space {
+        margin-bottom: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+        <fieldset class="space">
+          <table>
+            <thead>
+              <tr>
+                <th class="statusHeader">Status</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="identityHeader">Identity</th>
+                <th class="deleteHeader"></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.getIdentities().map((account, index) =>
+                this.renderIdentity(account, index)
+              )}
+            </tbody>
+          </table>
+        </fieldset>
+        ${when(
+          this.showLinkAnotherIdentity,
+          () => html`<fieldset>
+            <a href=${this.computeLinkAnotherIdentity()}>
+              <gr-button id="linkAnotherIdentity" link=""
+                >Link Another Identity</gr-button
+              >
+            </a>
+          </fieldset>`
+        )}
+      </div>
+      <gr-overlay id="overlay" with-backdrop>
+        <gr-confirm-delete-item-dialog
+          class="confirmDialog"
+          @confirm=${this.handleDeleteItemConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .item=${this.idName}
+          itemtypename="ID"
+        ></gr-confirm-delete-item-dialog>
+      </gr-overlay>`;
+  }
+
+  private renderIdentity(account: AccountExternalIdInfo, index: number) {
+    return html`<tr>
+      <td class="statusColumn">${account.trusted ? '' : 'Untrusted'}</td>
+      <td class="emailAddressColumn">${account.email_address}</td>
+      <td class="identityColumn">
+        ${account.identity.startsWith('mailto:') ? '' : account.identity}
+      </td>
+      <td class="deleteColumn">
+        <gr-button
+          data-index=${index}
+          class=${classMap({
+            deleteButton: true,
+            show: !!account.can_delete,
+          })}
+          @click=${() => this.handleDeleteItem(account.identity)}
+        >
+          Delete
+        </gr-button>
+      </td>
+    </tr>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.showLinkAnotherIdentity = this.computeShowLinkAnotherIdentity();
+    }
+  }
+
+  // private but used in test
+  getIdentities() {
+    return this.identities.filter(
+      account => !account.identity.startsWith('username:')
+    );
+  }
+
   loadData() {
-    return this.restApiService.getExternalIds().then(id => {
-      this._identities = id ?? [];
+    return this.restApiService.getExternalIds().then(ids => {
+      this.identities = ids ?? [];
     });
   }
 
-  _computeIdentity(id: string) {
-    return id && id.startsWith('mailto:') ? '' : id;
-  }
-
+  // private but used in test
   _computeHideDeleteClass(canDelete?: boolean) {
     return canDelete ? 'show' : '';
   }
 
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    return this.restApiService
-      .deleteAccountIdentity([this._idName!])
-      .then(() => {
-        this.loadData();
-      });
+  handleDeleteItemConfirm() {
+    this.overlay?.close();
+    assertIsDefined(this.idName);
+    return this.restApiService.deleteAccountIdentity([this.idName]).then(() => {
+      this.loadData();
+    });
   }
 
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
+  private handleConfirmDialogCancel() {
+    this.overlay?.close();
   }
 
-  _handleDeleteItem(e: PolymerDomRepeatEvent<AccountExternalIdInfo>) {
-    const name = e.model.item.identity;
-    if (!name) {
-      return;
-    }
-    this._idName = name;
-    this.$.overlay.open();
+  private handleDeleteItem(name: string) {
+    this.idName = name;
+    this.overlay?.open();
   }
 
-  _computeIsTrusted(item?: boolean) {
-    return item ? '' : 'Untrusted';
-  }
-
-  filterIdentities(item: AccountExternalIdInfo) {
-    return !item.identity.startsWith('username:');
-  }
-
-  _computeShowLinkAnotherIdentity(config?: ServerInfo) {
-    if (config?.auth?.auth_type) {
-      return AUTH.includes(config.auth.auth_type);
+  // private but used in test
+  computeShowLinkAnotherIdentity() {
+    if (this.serverConfig?.auth?.auth_type) {
+      return AUTH.includes(this.serverConfig.auth.auth_type);
     }
 
     return false;
   }
 
-  _computeLinkAnotherIdentity() {
+  private computeLinkAnotherIdentity() {
     const baseUrl = getBaseUrl() || '';
     let pathname = window.location.pathname;
     if (baseUrl) {
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
deleted file mode 100644
index a30840c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    tr th.emailAddressHeader,
-    tr th.identityHeader {
-      width: 15em;
-      padding: 0 10px;
-    }
-    tr td.statusColumn,
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      word-break: break-word;
-    }
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      padding: 4px 10px;
-      width: 15em;
-    }
-    .deleteButton {
-      float: right;
-    }
-    .deleteButton:not(.show) {
-      display: none;
-    }
-    .space {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset class="space">
-      <table>
-        <thead>
-          <tr>
-            <th class="statusHeader">Status</th>
-            <th class="emailAddressHeader">Email Address</th>
-            <th class="identityHeader">Identity</th>
-            <th class="deleteHeader"></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template
-            is="dom-repeat"
-            items="[[_identities]]"
-            filter="filterIdentities"
-          >
-            <tr>
-              <td class="statusColumn">[[_computeIsTrusted(item.trusted)]]</td>
-              <td class="emailAddressColumn">[[item.email_address]]</td>
-              <td class="identityColumn">
-                [[_computeIdentity(item.identity)]]
-              </td>
-              <td class="deleteColumn">
-                <gr-button
-                  class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                  on-click="_handleDeleteItem"
-                >
-                  Delete
-                </gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </fieldset>
-    <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
-      <fieldset>
-        <a href$="[[_computeLinkAnotherIdentity()]]">
-          <gr-button id="linkAnotherIdentity" link=""
-            >Link Another Identity</gr-button
-          >
-        </a>
-      </fieldset>
-    </template>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-delete-item-dialog
-      class="confirmDialog"
-      on-confirm="_handleDeleteItemConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      item="[[_idName]]"
-      itemTypeName="ID"
-    ></gr-confirm-delete-item-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
index ecc322ef7..8ee84bf 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -23,9 +23,8 @@
 import {ServerInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
 import {queryAll, queryAndAssert} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-identities');
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-identities tests', () => {
   let element: GrIdentities;
@@ -51,9 +50,72 @@
   setup(async () => {
     stubRestApi('getExternalIds').returns(Promise.resolve(ids));
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrIdentities>(
+      html`<gr-identities></gr-identities>`
+    );
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="gr-form-styles">
+        <fieldset class="space">
+          <table>
+            <thead>
+              <tr>
+                <th class="statusHeader">Status</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="identityHeader">Identity</th>
+                <th class="deleteHeader"></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="statusColumn">Untrusted</td>
+                <td class="emailAddressColumn">gerrit@example.com</td>
+                <td class="identityColumn">gerrit:gerrit</td>
+                <td class="deleteColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="deleteButton"
+                    data-index="0"
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td class="statusColumn"></td>
+                <td class="emailAddressColumn">gerrit2@example.com</td>
+                <td class="identityColumn"></td>
+                <td class="deleteColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="deleteButton show"
+                    data-index="1"
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </fieldset>
+      </div>
+      <gr-overlay
+        aria-hidden="true"
+        id="overlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-confirm-delete-item-dialog class="confirmDialog" itemtypename="ID">
+        </gr-confirm-delete-item-dialog
+      ></gr-overlay>`);
   });
 
   test('renders', () => {
@@ -78,70 +140,71 @@
     assert.equal(nameCells[1]!, 'gerrit2@example.com');
   });
 
-  test('_computeIdentity', () => {
-    assert.equal(element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
   test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
+    assert.notInclude(element.getIdentities(), ids[0]);
+    assert.include(element.getIdentities(), ids[1]);
   });
 
   test('delete id', async () => {
-    element._idName = 'mailto:gerrit2@example.com';
+    element.idName = 'mailto:gerrit2@example.com';
     const loadDataStub = sinon.stub(element, 'loadData');
-    await element._handleDeleteItemConfirm();
+    await element.handleDeleteItemConfirm();
     assert.isTrue(loadDataStub.called);
   });
 
-  test('_handleDeleteItem opens modal', () => {
-    const deleteBtn = queryAndAssert(element, '.deleteButton');
-    const deleteItem = sinon.stub(element, '_handleDeleteItem');
-    MockInteractions.tap(deleteBtn);
-    assert.isTrue(deleteItem.called);
+  test('handleDeleteItem opens modal', async () => {
+    const deleteBtn = queryAndAssert<GrButton>(element, '.deleteButton');
+    deleteBtn.click();
+    await element.updateComplete;
+    assert.isTrue(element.overlay?.opened);
   });
 
-  test('_computeShowLinkAnotherIdentity', () => {
+  test('computeShowLinkAnotherIdentity', () => {
     const config: ServerInfo = {
       ...createServerInfo(),
     };
 
     config.auth.auth_type = AuthType.OAUTH;
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.OPENID;
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.HTTP_LDAP;
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.LDAP;
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.HTTP;
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
-    assert.isFalse(element._computeShowLinkAnotherIdentity(undefined));
+    element.serverConfig = undefined;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
   });
 
-  test('_showLinkAnotherIdentity', () => {
+  test('showLinkAnotherIdentity', async () => {
     let config: ServerInfo = {
       ...createServerInfo(),
     };
     config.auth.auth_type = AuthType.OAUTH;
-
     element.serverConfig = config;
+    await element.updateComplete;
 
-    assert.isTrue(element._showLinkAnotherIdentity);
+    assert.isTrue(element.showLinkAnotherIdentity);
 
     config = {
       ...createServerInfo(),
     };
     config.auth.auth_type = AuthType.LDAP;
     element.serverConfig = config;
+    await element.updateComplete;
 
-    assert.isFalse(element._showLinkAnotherIdentity);
+    assert.isFalse(element.showLinkAnotherIdentity);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index c392a13..46c2956 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -1,98 +1,239 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-menu-editor_html';
-import {customElement, property} from '@polymer/decorators';
-import {TopMenuItemInfo} from '../../../types/common';
+import {PreferencesInfo, TopMenuItemInfo} from '../../../types/common';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {state, customElement} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {getAppContext} from '../../../services/app-context';
+import {deepEqual} from '../../../utils/deep-util';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {classMap} from 'lit/directives/class-map';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 
 @customElement('gr-menu-editor')
-export class GrMenuEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrMenuEditor extends LitElement {
+  @state()
+  menuItems: TopMenuItemInfo[] = [];
+
+  @state()
+  originalPrefs: PreferencesInfo = createDefaultPreferences();
+
+  @state()
+  newName = '';
+
+  @state()
+  newUrl = '';
+
+  private readonly userModel = getAppContext().userModel;
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        this.originalPrefs = prefs;
+        this.menuItems = [...prefs.my];
+      }
+    );
   }
 
-  @property({type: Array})
-  menuItems!: TopMenuItemInfo[];
+  static override styles = [
+    formStyles,
+    sharedStyles,
+    fontStyles,
+    menuPageStyles,
+    css`
+      .buttonColumn {
+        width: 2em;
+      }
+      .moveUpButton,
+      .moveDownButton {
+        width: 100%;
+      }
+      tbody tr:first-of-type td .moveUpButton,
+      tbody tr:last-of-type td .moveDownButton {
+        display: none;
+      }
+      td.urlCell {
+        word-break: break-word;
+      }
+      .newUrlInput {
+        min-width: 23em;
+      }
+    `,
+  ];
 
-  @property({type: String})
-  _newName?: string;
-
-  @property({type: String})
-  _newUrl?: string;
-
-  _handleMoveUpButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === 0) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const prev = this.menuItems[index - 1];
-    this.splice('menuItems', index - 1, 2, row, prev);
+  override render() {
+    const unchanged = deepEqual(this.menuItems, this.originalPrefs.my);
+    const classes = {
+      'heading-2': true,
+      edited: !unchanged,
+    };
+    return html`
+      <div class="gr-form-styles">
+        <h2 id="Menu" class=${classMap(classes)}>Menu</h2>
+        <fieldset id="menu">
+          <table>
+            <thead>
+              <tr>
+                <th>Name</th>
+                <th>URL</th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.menuItems.map((item, index) =>
+                this.renderMenuItemRow(item, index)
+              )}
+            </tbody>
+            <tfoot>
+              ${this.renderFooterRow()}
+            </tfoot>
+          </table>
+          <gr-button id="save" @click=${this.handleSave} ?disabled=${unchanged}
+            >Save changes</gr-button
+          >
+          <gr-button id="reset" link @click=${this.handleReset}
+            >Reset</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
   }
 
-  _handleMoveDownButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === this.menuItems.length - 1) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const next = this.menuItems[index + 1];
-    this.splice('menuItems', index, 2, next, row);
+  private renderMenuItemRow(item: TopMenuItemInfo, index: number) {
+    return html`
+      <tr>
+        <td>${item.name}</td>
+        <td class="urlCell">${item.url}</td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index - 1)}
+            class="moveUpButton"
+            >↑</gr-button
+          >
+        </td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index + 1)}
+            class="moveDownButton"
+            >↓</gr-button
+          >
+        </td>
+        <td>
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => {
+              this.menuItems.splice(index, 1);
+              this.requestUpdate('menuItems');
+            }}
+            class="remove-button"
+            >Delete</gr-button
+          >
+        </td>
+      </tr>
+    `;
   }
 
-  _handleDeleteButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    this.splice('menuItems', index, 1);
+  private renderFooterRow() {
+    return html`
+      <tr>
+        <th>
+          <iron-input
+            .bindValue=${this.newName}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newName = e.detail.value ?? '';
+            }}
+          >
+            <input
+              is="iron-input"
+              placeholder="New Title"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th>
+          <iron-input
+            .bindValue=${this.newUrl}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newUrl = e.detail.value ?? '';
+            }}
+          >
+            <input
+              class="newUrlInput"
+              placeholder="New URL"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th></th>
+        <th></th>
+        <th>
+          <gr-button
+            id="add"
+            link
+            ?disabled=${this.newName.length === 0 || this.newUrl.length === 0}
+            @click=${this.handleAddButton}
+            >Add</gr-button
+          >
+        </th>
+      </tr>
+    `;
   }
 
-  _handleAddButton() {
-    if (this._computeAddDisabled(this._newName, this._newUrl)) {
-      return;
-    }
+  private handleSave() {
+    this.userModel.updatePreferences({
+      ...this.originalPrefs,
+      my: this.menuItems,
+    });
+  }
 
-    this.splice('menuItems', this.menuItems.length, 0, {
-      name: this._newName,
-      url: this._newUrl,
+  private handleReset() {
+    this.menuItems = [...this.originalPrefs.my];
+  }
+
+  private swapItems(i: number, j: number) {
+    const max = this.menuItems.length - 1;
+    if (i < 0 || j < 0) return;
+    if (i > max || j > max) return;
+    const x = this.menuItems[i];
+    this.menuItems[i] = this.menuItems[j];
+    this.menuItems[j] = x;
+    this.requestUpdate('menuItems');
+  }
+
+  // visible for testing
+  handleAddButton() {
+    if (this.newName.length === 0 || this.newUrl.length === 0) return;
+
+    this.menuItems.push({
+      name: this.newName,
+      url: this.newUrl,
       target: '_blank',
     });
-
-    this._newName = '';
-    this._newUrl = '';
+    this.newName = '';
+    this.newUrl = '';
+    this.requestUpdate('menuItems');
   }
 
-  _computeAddDisabled(newName?: string, newUrl?: string) {
-    return !newName?.length || !newUrl?.length;
-  }
-
-  _handleInputKeydown(e: KeyboardEvent) {
+  private handleInputKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       e.stopPropagation();
-      this._handleAddButton();
+      this.handleAddButton();
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
deleted file mode 100644
index e4d66e2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .buttonColumn {
-      width: 2em;
-    }
-    .moveUpButton,
-    .moveDownButton {
-      width: 100%;
-    }
-    tbody tr:first-of-type td .moveUpButton,
-    tbody tr:last-of-type td .moveDownButton {
-      display: none;
-    }
-    td.urlCell {
-      word-break: break-word;
-    }
-    .newUrlInput {
-      min-width: 23em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <table>
-      <thead>
-        <tr>
-          <th class="nameHeader">Name</th>
-          <th class="url-header">URL</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[menuItems]]">
-          <tr>
-            <td>[[item.name]]</td>
-            <td class="urlCell">[[item.url]]</td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveUpButton"
-                class="moveUpButton"
-                >↑</gr-button
-              >
-            </td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveDownButton"
-                class="moveDownButton"
-                >↓</gr-button
-              >
-            </td>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <iron-input
-              placeholder="New Title"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newName}}"
-            >
-              <input
-                is="iron-input"
-                placeholder="New Title"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newName}}"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <iron-input
-              class="newUrlInput"
-              placeholder="New URL"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newUrl}}"
-            >
-              <input
-                class="newUrlInput"
-                is="iron-input"
-                placeholder="New URL"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newUrl}}"
-              />
-            </iron-input>
-          </th>
-          <th></th>
-          <th></th>
-          <th>
-            <gr-button
-              link=""
-              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-              on-click="_handleAddButton"
-              >Add</gr-button
-            >
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
index 9785ccb..c6130df 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
@@ -1,29 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-menu-editor';
 import {GrMenuEditor} from './gr-menu-editor';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {query, queryAll} from '../../../test/test-utils';
+import {query, queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {PaperButtonElement} from '@polymer/paper-button';
 import {TopMenuItemInfo} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-menu-editor');
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {createDefaultPreferences} from '../../../constants/constants';
 
 suite('gr-menu-editor tests', () => {
   let element: GrMenuEditor;
@@ -53,52 +42,229 @@
   }
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrMenuEditor>(
+      html`<gr-menu-editor></gr-menu-editor>`
+    );
     menu = [
       {url: '/first/url', name: 'first name', target: '_blank'},
       {url: '/second/url', name: 'second name', target: '_blank'},
       {url: '/third/url', name: 'third name', target: '_blank'},
     ];
-    element.set('menuItems', menu);
-    await flush();
+    element.originalPrefs = {...createDefaultPreferences(), my: menu};
+    element.menuItems = [...menu];
+    await element.updateComplete;
   });
 
   test('renders', () => {
-    const rows = queryAll(query<HTMLElement>(element, 'tbody')!, 'tr');
-    let tds;
-
-    assert.equal(rows.length, menu.length);
-    for (let i = 0; i < menu.length; i++) {
-      tds = rows[i].querySelectorAll('td');
-      assert.equal(tds[0].textContent, menu[i].name);
-      assert.equal(tds[1].textContent, menu[i].url);
-    }
-
-    assert.isTrue(
-      element._computeAddDisabled(element._newName, element._newUrl)
-    );
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="gr-form-styles">
+        <h2 class="heading-2" id="Menu">Menu</h2>
+        <fieldset id="menu">
+          <table>
+            <thead>
+              <tr>
+                <th>Name</th>
+                <th>URL</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>first name</td>
+                <td class="urlCell">/first/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>second name</td>
+                <td class="urlCell">/second/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>third name</td>
+                <td class="urlCell">/third/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+            <tfoot>
+              <tr>
+                <th>
+                  <iron-input>
+                    <input is="iron-input" placeholder="New Title" />
+                  </iron-input>
+                </th>
+                <th>
+                  <iron-input>
+                    <input class="newUrlInput" placeholder="New URL" />
+                  </iron-input>
+                </th>
+                <th></th>
+                <th></th>
+                <th>
+                  <gr-button
+                    aria-disabled="true"
+                    disabled=""
+                    id="add"
+                    link=""
+                    role="button"
+                    tabindex="-1"
+                  >
+                    Add
+                  </gr-button>
+                </th>
+              </tr>
+            </tfoot>
+          </table>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            id="save"
+            role="button"
+            tabindex="-1"
+          >
+            Save changes
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            id="reset"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Reset
+          </gr-button>
+        </fieldset>
+      </div>
+    `);
   });
 
-  test('_computeAddDisabled', () => {
-    assert.isTrue(element._computeAddDisabled('', ''));
-    assert.isTrue(element._computeAddDisabled('name', ''));
-    assert.isTrue(element._computeAddDisabled('', 'url'));
-    assert.isFalse(element._computeAddDisabled('name', 'url'));
+  test('add button disabled', async () => {
+    element.newName = 'test-name';
+    await element.updateComplete;
+    let addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isTrue(addButton.hasAttribute('disabled'));
+
+    element.newUrl = 'test-url';
+    await element.updateComplete;
+    addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
   });
 
-  test('add a new menu item', () => {
+  test('add a new menu item', async () => {
     const newName = 'new name';
     const newUrl = 'new url';
-
-    element._newName = newName;
-    element._newUrl = newUrl;
-    assert.isFalse(
-      element._computeAddDisabled(element._newName, element._newUrl)
-    );
-
     const originalMenuLength = element.menuItems.length;
 
-    element._handleAddButton();
+    element.newName = newName;
+    element.newUrl = newUrl;
+    await element.updateComplete;
+
+    const addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
+    addButton.click();
 
     assert.equal(element.menuItems.length, originalMenuLength + 1);
     assert.equal(element.menuItems[element.menuItems.length - 1].name, newName);
@@ -117,6 +283,37 @@
     assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
   });
 
+  test('move item down and save', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+    const saveButton = queryAndAssert<GrButton>(element, 'gr-button#save');
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+
+    move(element, 1, 'Down');
+    await element.updateComplete;
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isFalse(saveButton.hasAttribute('disabled'));
+
+    saveButton.click();
+    await waitUntil(() => element.originalPrefs.my[1].name === 'third name');
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+  });
+
+  test('move item down and reset', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+
+    const resetButton = queryAndAssert<GrButton>(element, 'gr-button#reset');
+    resetButton.click();
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+  });
+
   test('move items up', () => {
     assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
 
@@ -161,9 +358,9 @@
     assertMenuNamesEqual(element, []);
 
     // Add item to empty menu.
-    element._newName = 'new name';
-    element._newUrl = 'new url';
-    element._handleAddButton();
+    element.newName = 'new name';
+    element.newUrl = 'new url';
+    element.handleAddButton();
     assertMenuNamesEqual(element, ['new name']);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index 67ff0c4..6305639 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -17,22 +17,17 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-registration-dialog_html';
-import {customElement, property, observe} from '@polymer/decorators';
 import {ServerInfo, AccountDetailInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
-
-export interface GrRegistrationDialog {
-  $: {
-    name: HTMLInputElement;
-    username: HTMLInputElement;
-    displayName: HTMLInputElement;
-  };
-}
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when';
+import {ifDefined} from 'lit/directives/if-defined';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -41,11 +36,7 @@
 }
 
 @customElement('gr-registration-dialog')
-export class GrRegistrationDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrRegistrationDialog extends LitElement {
   /**
    * Fired when account details are changed.
    *
@@ -57,171 +48,286 @@
    *
    * @event close
    */
-  @property({type: String})
-  settingsUrl?: string;
+  @query('#name') nameInput?: HTMLInputElement;
 
-  @property({type: Object})
-  _account: Partial<AccountDetailInfo> = {};
+  @query('#username') usernameInput?: HTMLInputElement;
 
-  @property({type: Boolean})
-  _loading = true;
+  @query('#displayName') displayName?: HTMLInputElement;
 
-  @property({type: Boolean})
-  _saving = false;
+  @property() settingsUrl?: string;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @state() account: Partial<AccountDetailInfo> = {};
 
-  @property({
-    computed: '_computeUsernameMutable(_account.username)',
-    type: Boolean,
-  })
-  _usernameMutable = false;
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _hasUsernameChange?: boolean;
+  @state() saving = false;
 
-  @property({type: String, observer: '_usernameChanged'})
-  _username?: string;
+  @state() serverConfig?: ServerInfo;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeNameMutable(_serverConfig)',
-  })
-  _nameMutable?: boolean;
+  @state() usernameMutable = false;
 
-  @property({type: Boolean})
-  _hasNameChange?: boolean;
+  @state() hasUsernameChange?: boolean;
 
-  @property({type: Boolean})
-  _hasDisplayNameChange?: boolean;
+  @state() username?: string;
+
+  @state() nameMutable?: boolean;
+
+  @state() hasNameChange?: boolean;
+
+  @state() hasDisplayNameChange?: boolean;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
+  override connectedCallback() {
+    super.connectedCallback();
+    if (!this.getAttribute('role')) {
+      this.setAttribute('role', 'dialog');
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      :host {
+        display: block;
+      }
+      main {
+        max-width: 46em;
+      }
+      :host(.loading) main {
+        display: none;
+      }
+      .loadingMessage {
+        display: none;
+        font-style: italic;
+      }
+      :host(.loading) .loadingMessage {
+        display: block;
+      }
+      hr {
+        margin-top: var(--spacing-l);
+        margin-bottom: var(--spacing-l);
+      }
+      header {
+        border-bottom: 1px solid var(--border-color);
+        font-weight: var(--font-weight-bold);
+        margin-bottom: var(--spacing-l);
+      }
+      .container {
+        padding: var(--spacing-m) var(--spacing-xl);
+      }
+      footer {
+        display: flex;
+        justify-content: flex-end;
+      }
+      footer gr-button {
+        margin-left: var(--spacing-l);
+      }
+      input {
+        width: 20em;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="container gr-form-styles">
+      <header>Please confirm your contact information</header>
+      <div class="loadingMessage">Loading...</div>
+      <main>
+        <p>
+          The following contact information was automatically obtained when you
+          signed in to the site. This information is used to display who you are
+          to others, and to send updates to code reviews you have either started
+          or subscribed to.
+        </p>
+        <hr />
+        <section>
+          <span class="title">Full Name</span>
+          ${when(
+            this.nameMutable,
+            () => html`<span class="value">
+              <iron-input
+                .bindValue=${this.account.name}
+                @bind-value-changed=${(e: BindValueChangeEvent) => {
+                  const oldAccount = this.account;
+                  if (!oldAccount || oldAccount.name === e.detail.value) return;
+                  this.account = {...oldAccount, name: e.detail.value};
+                  this.hasNameChange = true;
+                }}
+              >
+                <input id="name" ?disabled=${this.saving} />
+              </iron-input>
+            </span>`,
+            () => html`<span class="value">${this.account.name}</span>`
+          )}
+        </section>
+        <section>
+          <span class="title">Display Name</span>
+          <span class="value">
+            <iron-input
+              .bindValue=${this.account.display_name}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                const oldAccount = this.account;
+                if (!oldAccount || oldAccount.display_name === e.detail.value) {
+                  return;
+                }
+                this.account = {...oldAccount, display_name: e.detail.value};
+                this.hasDisplayNameChange = true;
+              }}
+            >
+              <input id="displayName" ?disabled=${this.saving} />
+            </iron-input>
+          </span>
+        </section>
+        ${when(
+          this.computeUsernameEditable(),
+          () => html`<section>
+            <span class="title">Username</span>
+            ${when(
+              this.usernameMutable,
+              () => html` <span class="value">
+                <iron-input
+                  .bindValue=${this.username}
+                  @bind-value-changed=${(e: BindValueChangeEvent) => {
+                    if (!this.usernameInput || this.username === e.detail.value)
+                      return;
+                    this.username = e.detail.value;
+                    this.hasUsernameChange = true;
+                  }}
+                >
+                  <input id="username" ?disabled=${this.saving} />
+                </iron-input>
+              </span>`,
+              () => html`<span class="value">${this.username}</span>`
+            )}
+          </section>`
+        )}
+        <hr />
+        <p>
+          More configuration options for Gerrit may be found in the
+          <a @click=${this.close} href=${ifDefined(this.settingsUrl)}
+            >settings</a
+          >.
+        </p>
+      </main>
+      <footer>
+        <gr-button
+          id="closeButton"
+          link
+          ?disabled=${this.saving}
+          @click=${this.handleClose}
+          >Close</gr-button
+        >
+        <gr-button
+          id="saveButton"
+          primary
+          link
+          ?disabled=${this.computeSaveDisabled()}
+          @click=${this.handleSave}
+          >Save</gr-button
+        >
+      </footer>
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('account')) {
+      this.usernameMutable = !this.account.username;
+    }
+    if (changedProperties.has('serverConfig')) {
+      this.nameMutable = this.computeNameMutable();
+    }
+    if (changedProperties.has('loading')) {
+      this.classList.toggle('loading', this.loading);
+    }
   }
 
   loadData() {
-    this._loading = true;
+    this.loading = true;
 
     const loadAccount = this.restApiService.getAccount().then(account => {
       if (!account) return;
-      this._hasNameChange = false;
-      this._hasUsernameChange = false;
-      this._hasDisplayNameChange = false;
+      this.hasNameChange = false;
+      this.hasUsernameChange = false;
+      this.hasDisplayNameChange = false;
       // Provide predefined value for username to trigger computation of
       // username mutability.
       account.username = account.username || '';
 
-      this._account = account;
-      this._username = account.username;
+      this.account = account;
+      this.username = account.username;
     });
 
     const loadConfig = this.restApiService.getConfig().then(config => {
-      this._serverConfig = config;
+      this.serverConfig = config;
     });
 
     return Promise.all([loadAccount, loadConfig]).then(() => {
-      this._loading = false;
+      this.loading = false;
     });
   }
 
-  _usernameChanged() {
-    if (this._loading || !this._account) {
-      return;
-    }
-    this._hasUsernameChange =
-      (this._account.username || '') !== (this._username || '');
-  }
-
-  @observe('_account.display_name')
-  _displayNameChanged() {
-    if (this._loading || !this._account) {
-      return;
-    }
-    this._hasDisplayNameChange = true;
-  }
-
-  @observe('_account.name')
-  _nameChanged() {
-    if (this._loading || !this._account) {
-      return;
-    }
-    this._hasNameChange = true;
-  }
-
-  _computeUsernameMutable(username?: string) {
-    // Username may not be changed once it is set.
-    return !username;
-  }
-
-  _computeUsernameEditable(config?: ServerInfo) {
-    return !!config?.auth.editable_account_fields.includes(
+  // private but used in test
+  computeUsernameEditable() {
+    return !!this.serverConfig?.auth.editable_account_fields.includes(
       EditableAccountField.USER_NAME
     );
   }
 
-  _computeNameMutable(config: ServerInfo) {
-    return config.auth.editable_account_fields.includes(
+  private computeNameMutable() {
+    return !!this.serverConfig?.auth.editable_account_fields.includes(
       EditableAccountField.FULL_NAME
     );
   }
 
-  _save() {
-    this._saving = true;
+  // private but used in test
+  save() {
+    this.saving = true;
 
     const promises = [];
     // Note that we are intentionally not acting on this._username being the
     // empty string (which is falsy).
-    if (this._hasUsernameChange && this._usernameMutable && this._username) {
-      promises.push(this.restApiService.setAccountUsername(this._username));
+    if (this.hasUsernameChange && this.usernameMutable && this.username) {
+      promises.push(this.restApiService.setAccountUsername(this.username));
     }
 
-    if (this._hasNameChange && this._nameMutable && this._account?.name) {
-      promises.push(this.restApiService.setAccountName(this._account.name));
+    if (this.hasNameChange && this.nameMutable && this.account?.name) {
+      promises.push(this.restApiService.setAccountName(this.account.name));
     }
 
-    if (this._hasDisplayNameChange && this._account?.display_name) {
+    if (this.hasDisplayNameChange && this.account?.display_name) {
       promises.push(
-        this.restApiService.setAccountDisplayName(this._account.display_name)
+        this.restApiService.setAccountDisplayName(this.account.display_name)
       );
     }
 
     return Promise.all(promises).then(() => {
-      this._saving = false;
+      this.saving = false;
       fireEvent(this, 'account-detail-update');
     });
   }
 
-  _handleSave(e: Event) {
+  private handleSave(e: Event) {
     e.preventDefault();
-    this._save().then(() => this.close());
+    this.save().then(() => this.close());
   }
 
-  _handleClose(e: Event) {
+  private handleClose(e: Event) {
     e.preventDefault();
     this.close();
   }
 
-  close() {
-    this._saving = true; // disable buttons indefinitely
+  private close() {
+    this.saving = true; // disable buttons indefinitely
     fireEvent(this, 'close');
   }
 
-  _computeSaveDisabled(
-    displayName?: string,
-    name?: string,
-    username?: string,
-    saving?: boolean
-  ) {
-    return saving || (!displayName && !name && !username);
-  }
-
-  @observe('_loading')
-  _loadingChanged() {
-    this.classList.toggle('loading', this._loading);
+  // private but used in test
+  computeSaveDisabled() {
+    return (
+      this.saving ||
+      (!this.account?.display_name && !this.account.name && !this.username)
+    );
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
deleted file mode 100644
index 4484631..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
+++ /dev/null
@@ -1,128 +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-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    main {
-      max-width: 46em;
-    }
-    :host(.loading) main {
-      display: none;
-    }
-    .loadingMessage {
-      display: none;
-      font-style: italic;
-    }
-    :host(.loading) .loadingMessage {
-      display: block;
-    }
-    hr {
-      margin-top: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
-    }
-    header {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-      margin-bottom: var(--spacing-l);
-    }
-    .container {
-      padding: var(--spacing-m) var(--spacing-xl);
-    }
-    footer {
-      display: flex;
-      justify-content: flex-end;
-    }
-    footer gr-button {
-      margin-left: var(--spacing-l);
-    }
-    input {
-      width: 20em;
-    }
-  </style>
-  <div class="container gr-form-styles">
-    <header>Please confirm your contact information</header>
-    <div class="loadingMessage">Loading...</div>
-    <main>
-      <p>
-        The following contact information was automatically obtained when you
-        signed in to the site. This information is used to display who you are
-        to others, and to send updates to code reviews you have either started
-        or subscribed to.
-      </p>
-      <hr />
-      <section>
-        <span class="title">Full Name</span>
-        <span hidden$="[[_nameMutable]]" class="value">[[_account.name]]</span>
-        <span hidden$="[[!_nameMutable]]" class="value">
-          <iron-input bind-value="{{_account.name}}">
-            <input id="name" disabled="[[_saving]]" />
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Display Name</span>
-        <span class="value">
-          <iron-input bind-value="{{_account.display_name}}">
-            <input id="displayName" disabled="[[_saving]]" />
-          </iron-input>
-        </span>
-      </section>
-      <template is="dom-if" if="[[_computeUsernameEditable(_serverConfig)]]">
-        <section>
-          <span class="title">Username</span>
-          <span hidden$="[[_usernameMutable]]" class="value"
-            >[[_username]]</span
-          >
-          <span hidden$="[[!_usernameMutable]]" class="value">
-            <iron-input bind-value="{{_username}}">
-              <input id="username" disabled="[[_saving]]" />
-            </iron-input>
-          </span>
-        </section>
-      </template>
-      <hr />
-      <p>
-        More configuration options for Gerrit may be found in the
-        <a on-click="close" href$="[[settingsUrl]]">settings</a>.
-      </p>
-    </main>
-    <footer>
-      <gr-button
-        id="closeButton"
-        link=""
-        disabled="[[_saving]]"
-        on-click="_handleClose"
-        >Close</gr-button
-      >
-      <gr-button
-        id="saveButton"
-        primary=""
-        link=""
-        disabled="[[_computeSaveDisabled(_account.display_name, _account.name, _username, _saving)]]"
-        on-click="_handleSave"
-        >Save</gr-button
-      >
-    </footer>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
index 78b7d60..4118a20 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
@@ -19,11 +19,13 @@
 import {GrRegistrationDialog} from './gr-registration-dialog';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {AccountDetailInfo, Timestamp} from '../../../types/common';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {AuthType, EditableAccountField} from '../../../constants/constants';
-import {createServerInfo} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-registration-dialog');
+import {
+  createAccountWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-registration-dialog tests', () => {
   let element: GrRegistrationDialog;
@@ -31,7 +33,7 @@
 
   let _listeners: {[key: string]: EventListenerOrEventListenerObject};
 
-  setup(() => {
+  setup(async () => {
     _listeners = {};
 
     account = {
@@ -70,9 +72,12 @@
       })
     );
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrRegistrationDialog>(
+      html`<gr-registration-dialog></gr-registration-dialog>`
+    );
 
-    return element.loadData();
+    await element.loadData();
+    await element.updateComplete;
   });
 
   teardown(() => {
@@ -92,7 +97,7 @@
 
   function save() {
     const promise = listen('account-detail-update');
-    MockInteractions.tap(queryAndAssert(element, '#saveButton'));
+    queryAndAssert<GrButton>(element, '#saveButton').click();
     return promise;
   }
 
@@ -101,31 +106,103 @@
     if (opt_action) {
       opt_action();
     } else {
-      MockInteractions.tap(queryAndAssert(element, '#closeButton'));
+      queryAndAssert<GrButton>(element, '#closeButton').click();
     }
     return promise;
   }
 
+  test('renders', () => {
+    // cannot format with /* HTML */, because it breaks test
+    expect(element).shadowDom.to.equal(/* HTML*/ `<div
+      class="container gr-form-styles"
+    >
+      <header>Please confirm your contact information</header>
+      <div class="loadingMessage">Loading...</div>
+      <main>
+        <p>
+        The following contact information was automatically obtained when you
+          signed in to the site. This information is used to display who you are
+          to others, and to send updates to code reviews you have either started
+          or subscribed to.
+        </p>
+        <hr />
+        <section>
+          <span class="title"> Full Name </span>
+          <span class="value">
+            <iron-input>
+              <input id="name">
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <span class="title"> Display Name </span>
+          <span class="value">
+            <iron-input> <input id="displayName" /> </iron-input>
+          </span>
+        </section>
+        <section>
+          <span class="title"> Username </span>
+          <span class="value">
+            <iron-input>
+              <input id="username">
+            </iron-input>
+          </span>
+        </section>
+        <hr />
+        <p>
+          More configuration options for Gerrit may be found in the
+          <a> settings </a> .
+        </p>
+      </main>
+      <footer>
+        <gr-button
+          aria-disabled="false"
+          id="closeButton"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Close
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="saveButton"
+          link=""
+          primary=""
+          role="button"
+          tabindex="0"
+        >
+          Save
+        </gr-button>
+      </footer>
+    </div>`);
+  });
+
   test('fires the close event on close', async () => {
     await close();
   });
 
   test('fires the close event on save', async () => {
-    await close(() =>
-      MockInteractions.tap(queryAndAssert(element, '#saveButton'))
-    );
+    await close(() => {
+      queryAndAssert<GrButton>(element, '#saveButton').click();
+    });
   });
 
   test('saves account details', async () => {
-    await flush();
+    await element.updateComplete;
 
-    element.set('_account.username', '');
-    element._hasUsernameChange = false;
-    assert.isTrue(element._usernameMutable);
+    element.account.username = '';
+    element.hasUsernameChange = false;
+    await element.updateComplete;
+    assert.isTrue(element.usernameMutable);
 
-    element.set('_username', 'new username');
-    element.set('_account.name', 'new name');
-    element.set('_account.display_name', 'new display name');
+    element.username = 'new username';
+    element.hasUsernameChange = true;
+    element.account.name = 'new name';
+    element.hasNameChange = true;
+    element.account.display_name = 'new display name';
+    element.hasDisplayNameChange = true;
+    await element.updateComplete;
 
     // Nothing should be committed yet.
     assert.equal(account.name, 'name');
@@ -139,39 +216,43 @@
     assert.equal(account.display_name, 'new display name');
   });
 
-  test('save btn disabled', () => {
-    const compute = element._computeSaveDisabled;
-    assert.isTrue(compute('', '', '', false));
-    assert.isFalse(compute('', '', 'test', false));
-    assert.isFalse(compute('', 'test', '', false));
-    assert.isFalse(compute('test', '', '', false));
-    assert.isTrue(compute('test', 'test', 'test', true));
-    assert.isFalse(compute('test', 'test', 'test', false));
+  test('save btn disabled', async () => {
+    element.account = {};
+    element.saving = false;
+    await element.updateComplete;
+    assert.isTrue(element.computeSaveDisabled());
+    element.account = {
+      ...createAccountWithId(),
+      display_name: 'test',
+      name: 'test',
+    };
+    element.username = 'test';
+    element.saving = true;
+    await element.updateComplete;
+    assert.isTrue(element.computeSaveDisabled());
+    element.saving = false;
+    await element.updateComplete;
+    assert.isFalse(element.computeSaveDisabled());
   });
 
-  test('_computeUsernameMutable', () => {
-    assert.isTrue(element._computeUsernameMutable(undefined));
-    assert.isFalse(element._computeUsernameMutable('abc'));
-  });
-
-  test('_computeUsernameEditable', () => {
-    assert.isTrue(
-      element._computeUsernameEditable({
-        ...createServerInfo(),
-        auth: {
-          auth_type: AuthType.HTTP,
-          editable_account_fields: [EditableAccountField.USER_NAME],
-        },
-      })
-    );
-    assert.isFalse(
-      element._computeUsernameEditable({
-        ...createServerInfo(),
-        auth: {
-          auth_type: AuthType.HTTP,
-          editable_account_fields: [],
-        },
-      })
-    );
+  test('_computeUsernameEditable', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.HTTP,
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    await element.updateComplete;
+    assert.isTrue(element.computeUsernameEditable());
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.HTTP,
+        editable_account_fields: [],
+      },
+    };
+    await element.updateComplete;
+    assert.isFalse(element.computeUsernameEditable());
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
index 64409d5..e132128 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
@@ -46,6 +46,6 @@
 
   override render() {
     const anchor = this.anchor ?? '';
-    return html`<h2 id="${anchor}" class="heading-2">${this.title}</h2>`;
+    return html`<h2 id=${anchor} class="heading-2">${this.title}</h2>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
index 9e4ea0a..18c8bea 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
@@ -40,7 +40,7 @@
   override render() {
     const href = this.href ?? '';
     return html` <div class="navStyles">
-      <li><a href="${href}">${this.title}</a></li>
+      <li><a href=${href}>${this.title}</a></li>
     </div>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 2da3765..76259b9 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -16,16 +16,9 @@
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-page-nav-styles';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
-import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
@@ -40,22 +33,14 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-settings-view_html';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
 import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
 import {GrGroupList} from '../gr-group-list/gr-group-list';
 import {GrIdentities} from '../gr-identities/gr-identities';
-import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {
-  PreferencesInput,
-  ServerInfo,
-  TopMenuItemInfo,
-} from '../../../types/common';
+import {PreferencesInput, ServerInfo} from '../../../types/common';
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
@@ -63,6 +48,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
 import {
+  ColumnNames,
   DateFormat,
   DefaultBase,
   DiffViewMode,
@@ -70,25 +56,17 @@
   EmailStrategy,
   TimeFormat,
 } from '../../../constants/constants';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 import {windowLocationReload} from '../../../utils/dom-util';
-
-const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
-  'changes_per_page',
-  'date_format',
-  'time_format',
-  'email_strategy',
-  'diff_view',
-  'publish_comments_on_push',
-  'disable_keyboard_shortcuts',
-  'disable_token_highlighting',
-  'work_in_progress_by_default',
-  'default_base_for_merges',
-  'signed_off_by',
-  'email_format',
-  'size_bar_in_change_table',
-  'relative_date_in_change_table',
-];
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -103,44 +81,8 @@
   LocalPrefsToPrefs,
 }
 
-type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
-
-export interface GrSettingsView {
-  $: {
-    accountInfo: GrAccountInfo;
-    watchedProjectsEditor: GrWatchedProjectsEditor;
-    groupList: GrGroupList;
-    identities: GrIdentities;
-    editPrefs: GrEditPreferences;
-    diffPrefs: GrDiffPreferences;
-    sshEditor: GrSshEditor;
-    gpgEditor: GrGpgEditor;
-    emailEditor: GrEmailEditor;
-    insertSignedOff: HTMLInputElement;
-    workInProgressByDefault: HTMLInputElement;
-    showSizeBarsInFileList: HTMLInputElement;
-    publishCommentsOnPush: HTMLInputElement;
-    disableKeyboardShortcuts: HTMLInputElement;
-    disableTokenHighlighting: HTMLInputElement;
-    relativeDateInChangeTable: HTMLInputElement;
-    changesPerPageSelect: HTMLInputElement;
-    dateTimeFormatSelect: HTMLInputElement;
-    timeFormatSelect: HTMLInputElement;
-    emailNotificationsSelect: HTMLInputElement;
-    emailFormatSelect: HTMLInputElement;
-    defaultBaseForMergesSelect: HTMLInputElement;
-    diffViewSelect: HTMLInputElement;
-    menu: HTMLFieldSetElement;
-    resetButton: GrButton;
-  };
-}
-
 @customElement('gr-settings-view')
-export class GrSettingsView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSettingsView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -153,75 +95,106 @@
    * @event show-alert
    */
 
-  @property({type: Object})
-  prefs: PreferencesInput = {};
+  @query('#accountInfo', true) accountInfo!: GrAccountInfo;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  @query('#watchedProjectsEditor', true)
+  watchedProjectsEditor!: GrWatchedProjectsEditor;
 
-  @property({type: Boolean})
-  _accountInfoChanged?: boolean;
+  @query('#groupList', true) groupList!: GrGroupList;
 
-  @property({type: Object})
-  _localPrefs: PreferencesInput = {};
+  @query('#identities', true) identities!: GrIdentities;
 
-  @property({type: Array})
-  _localChangeTableColumns: string[] = [];
+  @query('#diffPrefs') diffPrefs!: GrDiffPreferences;
 
-  @property({type: Array})
-  _localMenu: LocalMenuItemInfo[] = [];
+  @query('#sshEditor') sshEditor?: GrSshEditor;
 
-  @property({type: Boolean})
-  _loading = true;
+  @query('#gpgEditor') gpgEditor?: GrGpgEditor;
 
-  @property({type: Boolean})
-  _changeTableChanged = false;
+  @query('#emailEditor', true) emailEditor!: GrEmailEditor;
 
-  @property({type: Boolean})
-  _prefsChanged = false;
+  @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _diffPrefsChanged = false;
+  @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _editPrefsChanged = false;
+  @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _menuChanged = false;
+  @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _watchedProjectsChanged = false;
+  @query('#disableKeyboardShortcuts')
+  disableKeyboardShortcuts!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _keysChanged = false;
+  @query('#disableTokenHighlighting')
+  disableTokenHighlighting!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _gpgKeysChanged = false;
+  @query('#relativeDateInChangeTable')
+  relativeDateInChangeTable!: HTMLInputElement;
 
-  @property({type: String})
-  _newEmail?: string;
+  @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _addingEmail = false;
+  @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
 
-  @property({type: String})
-  _lastSentVerificationEmail?: string | null = null;
+  @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @query('#emailNotificationsSelect')
+  emailNotificationsSelect!: HTMLInputElement;
 
-  @property({type: String})
-  _docsBaseUrl?: string | null;
+  @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _emailsChanged = false;
+  @query('#defaultBaseForMergesSelect')
+  defaultBaseForMergesSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _showNumber?: boolean;
+  @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _isDark = false;
+  @state() prefs: PreferencesInput = {};
 
+  @property({type: Object}) params?: AppElementParams;
+
+  @state() private accountInfoChanged = false;
+
+  @state() private localPrefs: PreferencesInput = {};
+
+  // private but used in test
+  @state() localChangeTableColumns: string[] = [];
+
+  @state() private loading = true;
+
+  @state() private changeTableChanged = false;
+
+  // private but used in test
+  @state() prefsChanged = false;
+
+  @state() private diffPrefsChanged = false;
+
+  @state() private watchedProjectsChanged = false;
+
+  @state() private keysChanged = false;
+
+  @state() private gpgKeysChanged = false;
+
+  // private but used in test
+  @state() newEmail?: string;
+
+  // private but used in test
+  @state() addingEmail = false;
+
+  // private but used in test
+  @state() lastSentVerificationEmail?: string | null = null;
+
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
+
+  // private but used in test
+  @state() docsBaseUrl?: string | null;
+
+  @state() private emailsChanged = false;
+
+  // private but used in test
+  @state() showNumber?: boolean;
+
+  // private but used in test
+  @state() isDark = false;
+
+  // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -232,15 +205,16 @@
     // we need to manually calling scrollIntoView when hash changed
     window.addEventListener('location-change', this.handleLocationChange);
     fireTitleChange(this, 'Settings');
+  }
 
-    this._isDark = !!window.localStorage.getItem('dark-theme');
+  override firstUpdated() {
+    this.isDark = !!window.localStorage.getItem('dark-theme');
 
     const promises: Array<Promise<unknown>> = [
-      this.$.accountInfo.loadData(),
-      this.$.watchedProjectsEditor.loadData(),
-      this.$.groupList.loadData(),
-      this.$.identities.loadData(),
-      this.$.editPrefs.loadData(),
+      this.accountInfo.loadData(),
+      this.watchedProjectsEditor.loadData(),
+      this.groupList.loadData(),
+      this.identities.loadData(),
     ];
 
     // TODO(dhruvsri): move this to the service
@@ -250,12 +224,11 @@
           throw new Error('getPreferences returned undefined');
         }
         this.prefs = prefs;
-        this._showNumber = !!prefs.legacycid_in_change_table;
-        this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this._localMenu = this._cloneMenu(prefs.my);
-        this._localChangeTableColumns =
+        this.showNumber = !!prefs.legacycid_in_change_table;
+        this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this.localChangeTableColumns =
           prefs.change_table.length === 0
-            ? columnNames
+            ? Object.values(ColumnNames)
             : prefs.change_table.map(column =>
                 column === 'Project' ? 'Repo' : column
               );
@@ -264,24 +237,20 @@
 
     promises.push(
       this.restApiService.getConfig().then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
         const configPromises: Array<Promise<void>> = [];
 
-        if (this._serverConfig && this._serverConfig.sshd) {
-          configPromises.push(this.$.sshEditor.loadData());
+        if (this.serverConfig?.sshd && this.sshEditor) {
+          configPromises.push(this.sshEditor.loadData());
         }
 
-        if (
-          this._serverConfig &&
-          this._serverConfig.receive &&
-          this._serverConfig.receive.enable_signed_push
-        ) {
-          configPromises.push(this.$.gpgEditor.loadData());
+        if (this.serverConfig?.receive?.enable_signed_push && this.gpgEditor) {
+          configPromises.push(this.gpgEditor.loadData());
         }
 
         configPromises.push(
           getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
-            this._docsBaseUrl = baseUrl;
+            this.docsBaseUrl = baseUrl;
           })
         );
 
@@ -301,26 +270,784 @@
             if (message) {
               fireAlert(this, message);
             }
-            this.$.emailEditor.loadData();
+            this.emailEditor.loadData();
           })
       );
     } else {
-      promises.push(this.$.emailEditor.loadData());
+      promises.push(this.emailEditor.loadData());
     }
 
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
-      this._loading = false;
+      this.loading = false;
 
       // Handle anchor tag for initial load
       this.handleLocationChange();
     });
   }
 
+  static override styles = [
+    sharedStyles,
+    paperStyles,
+    fontStyles,
+    formStyles,
+    menuPageStyles,
+    pageNavStyles,
+    css`
+      :host {
+        color: var(--primary-text-color);
+      }
+      h2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      .newEmailInput {
+        width: 20em;
+      }
+      #email {
+        margin-bottom: var(--spacing-l);
+      }
+      .main section.darkToggle {
+        display: block;
+      }
+      .filters p,
+      .darkToggle p {
+        margin-bottom: var(--spacing-l);
+      }
+      .queryExample em {
+        color: violet;
+      }
+      .toggle {
+        align-items: center;
+        display: flex;
+        margin-bottom: var(--spacing-l);
+        margin-right: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    const isLoading = this.loading || this.loading === undefined;
+    return html`<div class="loading" ?hidden=${!isLoading}>Loading...</div>
+      <div ?hidden=${isLoading}>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile">Profile</a></li>
+            <li><a href="#Preferences">Preferences</a></li>
+            <li><a href="#DiffPreferences">Diff Preferences</a></li>
+            <li><a href="#EditPreferences">Edit Preferences</a></li>
+            <li><a href="#Menu">Menu</a></li>
+            <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
+            <li><a href="#Notifications">Notifications</a></li>
+            <li><a href="#EmailAddresses">Email Addresses</a></li>
+            ${when(
+              this.showHttpAuth(),
+              () =>
+                html`<li><a href="#HTTPCredentials">HTTP Credentials</a></li>`
+            )}
+            ${when(
+              this.serverConfig?.sshd,
+              () => html`<li><a href="#SSHKeys"> SSH Keys </a></li>`
+            )}
+            ${when(
+              this.serverConfig?.receive?.enable_signed_push,
+              () => html`<li><a href="#GPGKeys"> GPG Keys </a></li>`
+            )}
+            <li><a href="#Groups">Groups</a></li>
+            <li><a href="#Identities">Identities</a></li>
+            ${when(
+              this.serverConfig?.auth.use_contributor_agreements,
+              () => html`<li><a href="#Agreements">Agreements</a></li>`
+            )}
+            <li><a href="#MailFilters">Mail Filters</a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="main gr-form-styles">
+          <h1 class="heading-1">User Settings</h1>
+          <h2 id="Theme">Theme</h2>
+          <section class="darkToggle">
+            <div class="toggle">
+              <paper-toggle-button
+                aria-labelledby="darkThemeToggleLabel"
+                ?checked=${this.isDark}
+                @change=${this.handleToggleDark}
+                @click=${this.onTapDarkToggle}
+              ></paper-toggle-button>
+              <div id="darkThemeToggleLabel">Dark theme</div>
+            </div>
+          </section>
+          <h2
+            id="Profile"
+            class=${this.computeHeaderClass(this.accountInfoChanged)}
+          >
+            Profile
+          </h2>
+          <fieldset id="profile">
+            <gr-account-info
+              id="accountInfo"
+              ?hasUnsavedChanges=${this.accountInfoChanged}
+              @unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.accountInfoChanged = e.detail.value;
+              }}
+            ></gr-account-info>
+            <gr-button
+              @click=${() => {
+                this.accountInfo.save();
+              }}
+              ?disabled=${!this.accountInfoChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Preferences"
+            class=${this.computeHeaderClass(this.prefsChanged)}
+          >
+            Preferences
+          </h2>
+          <fieldset id="preferences">
+            ${this.renderChangesPerPages()} ${this.renderDateTimeFormat()}
+            ${this.renderEmailNotification()} ${this.renderEmailFormat()}
+            ${this.renderDefaultBaseForMerges()}
+            ${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
+            ${this.renderShowSizeBarsInFileList()}
+            ${this.renderPublishCommentsOnPush()}
+            ${this.renderWorkInProgressByDefault()}
+            ${this.renderDisableKeyboardShortcuts()}
+            ${this.renderDisableTokenHighlighting()}
+            ${this.renderInsertSignedOff()}
+            <gr-button
+              id="savePrefs"
+              @click=${this.handleSavePreferences}
+              ?disabled=${!this.prefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="DiffPreferences"
+            class=${this.computeHeaderClass(this.diffPrefsChanged)}
+          >
+            Diff Preferences
+          </h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences
+              id="diffPrefs"
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.diffPrefsChanged = e.detail.value;
+              }}
+            ></gr-diff-preferences>
+            <gr-button
+              id="saveDiffPrefs"
+              @click=${() => {
+                this.diffPrefs.save();
+              }}
+              ?disabled=${!this.diffPrefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
+          <gr-menu-editor></gr-menu-editor>
+          <h2
+            id="ChangeTableColumns"
+            class=${this.computeHeaderClass(this.changeTableChanged)}
+          >
+            Change Table Columns
+          </h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor
+              .showNumber=${this.showNumber}
+              @show-number-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.showNumber = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+              .serverConfig=${this.serverConfig}
+              .displayedColumns=${this.localChangeTableColumns}
+              @displayed-columns-changed=${(e: ValueChangedEvent<string[]>) => {
+                this.localChangeTableColumns = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+            >
+            </gr-change-table-editor>
+            <gr-button
+              id="saveChangeTable"
+              @click=${this.handleSaveChangeTable}
+              ?disabled=${!this.changeTableChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Notifications"
+            class=${this.computeHeaderClass(this.watchedProjectsChanged)}
+          >
+            Notifications
+          </h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor
+              ?hasUnsavedChanges=${this.watchedProjectsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.watchedProjectsChanged = e.detail.value;
+              }}
+              id="watchedProjectsEditor"
+            ></gr-watched-projects-editor>
+            <gr-button
+              @click=${() => {
+                this.watchedProjectsEditor.save();
+              }}
+              ?disabled=${!this.watchedProjectsChanged}
+              id="_handleSaveWatchedProjects"
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="EmailAddresses"
+            class=${this.computeHeaderClass(this.emailsChanged)}
+          >
+            Email Addresses
+          </h2>
+          <fieldset id="email">
+            <gr-email-editor
+              id="emailEditor"
+              ?hasUnsavedChanges=${this.emailsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.emailsChanged = e.detail.value;
+              }}
+            ></gr-email-editor>
+            <gr-button
+              @click=${() => {
+                this.emailEditor.save();
+              }}
+              ?disabled=${!this.emailsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title">New email address</span>
+              <span class="value">
+                <iron-input
+                  class="newEmailInput"
+                  .bindValue=${this.newEmail}
+                  @bind-value-changed=${(e: BindValueChangeEvent) => {
+                    this.newEmail = e.detail.value;
+                  }}
+                  @keydown=${this.handleNewEmailKeydown}
+                >
+                  <input
+                    class="newEmailInput"
+                    type="text"
+                    ?disabled=${this.addingEmail}
+                    @keydown=${this.handleNewEmailKeydown}
+                    placeholder="email@example.com"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section
+              id="verificationSentMessage"
+              ?hidden=${!this.lastSentVerificationEmail}
+            >
+              <p>
+                A verification email was sent to
+                <em>${this.lastSentVerificationEmail}</em>. Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              ?disabled=${!this.computeAddEmailButtonEnabled()}
+              @click=${this.handleAddEmailButton}
+              >Send verification</gr-button
+            >
+          </fieldset>
+          ${when(
+            this.showHttpAuth(),
+            () => html` <div>
+              <h2 id="HTTPCredentials">HTTP Credentials</h2>
+              <fieldset>
+                <gr-http-password id="httpPass"></gr-http-password>
+              </fieldset>
+            </div>`
+          )}
+          ${when(
+            this.serverConfig?.sshd,
+            () => html`<h2
+                id="SSHKeys"
+                class=${this.computeHeaderClass(this.keysChanged)}
+              >
+                SSH keys
+              </h2>
+              <gr-ssh-editor
+                id="sshEditor"
+                ?hasUnsavedChanges=${this.keysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.keysChanged = e.detail.value;
+                }}
+              ></gr-ssh-editor>`
+          )}
+          ${when(
+            this.serverConfig?.receive?.enable_signed_push,
+            () => html`<div>
+              <h2
+                id="GPGKeys"
+                class=${this.computeHeaderClass(this.gpgKeysChanged)}
+              >
+                GPG keys
+              </h2>
+              <gr-gpg-editor
+                id="gpgEditor"
+                ?hasUnsavedChanges=${this.gpgKeysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.gpgKeysChanged = e.detail.value;
+                }}
+              ></gr-gpg-editor>
+            </div>`
+          )}
+          <h2 id="Groups">Groups</h2>
+          <fieldset>
+            <gr-group-list id="groupList"></gr-group-list>
+          </fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities
+              id="identities"
+              .serverConfig=${this.serverConfig}
+            ></gr-identities>
+          </fieldset>
+          ${when(
+            this.serverConfig?.auth.use_contributor_agreements,
+            () => html`<h2 id="Agreements">Agreements</h2>
+              <fieldset>
+                <gr-agreements-list id="agreementsList"></gr-agreements-list>
+              </fieldset>`
+          )}
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href=${this.getFilterDocsLink(this.docsBaseUrl)}
+                target="_blank"
+                rel="nofollow"
+                >Gerrit documentation</a
+              >
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em>Owner name</em>
+                      &lt;<em>owner.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em>branch-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em>project-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em>Change ID</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em>change number</em>"
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`;
+  }
+
   override disconnectedCallback() {
     window.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
+  private renderChangesPerPages() {
+    return html`
+      <section>
+        <label class="title" for="changesPerPageSelect">Changes per page</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.changes_per_page)}
+            @change=${() => {
+              this.localPrefs.changes_per_page = Number(
+                this.changesPerPageSelect.value
+              ) as 10 | 25 | 50 | 100;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="changesPerPageSelect">
+              <option value="10">10 rows per page</option>
+              <option value="25">25 rows per page</option>
+              <option value="50">50 rows per page</option>
+              <option value="100">100 rows per page</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDateTimeFormat() {
+    return html`
+      <section>
+        <label class="title" for="dateTimeFormatSelect">Date/time format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.date_format)}
+            @change=${() => {
+              this.localPrefs.date_format = this.dateTimeFormatSelect
+                .value as DateFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="dateTimeFormatSelect">
+              <option value="STD">Jun 3 ; Jun 3, 2016</option>
+              <option value="US">06/03 ; 06/03/16</option>
+              <option value="ISO">06-03 ; 2016-06-03</option>
+              <option value="EURO">3. Jun ; 03.06.2016</option>
+              <option value="UK">03/06 ; 03/06/2016</option>
+            </select>
+          </gr-select>
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.time_format)}
+            aria-label="Time Format"
+            @change=${() => {
+              this.localPrefs.time_format = this.timeFormatSelect
+                .value as TimeFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="timeFormatSelect">
+              <option value="HHMM_12">4:10 PM</option>
+              <option value="HHMM_24">16:10</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailNotification() {
+    return html`
+      <section>
+        <label class="title" for="emailNotificationsSelect"
+          >Email notifications</label
+        >
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_strategy)}
+            @change=${() => {
+              this.localPrefs.email_strategy = this.emailNotificationsSelect
+                .value as EmailStrategy;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="emailNotificationsSelect">
+              <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+              <option value="ENABLED">Only comments left by others</option>
+              <option value="ATTENTION_SET_ONLY">
+                Only when I am in the attention set
+              </option>
+              <option value="DISABLED">None</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailFormat() {
+    if (!this.localPrefs.email_format) return nothing;
+    return html`
+      <section>
+        <label class="title" for="emailFormatSelect">Email format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_format)}
+            @change=${() => {
+              this.localPrefs.email_format = this.emailFormatSelect
+                .value as EmailFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="emailFormatSelect">
+              <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+              <option value="PLAINTEXT">Plaintext only</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDefaultBaseForMerges() {
+    if (!this.localPrefs.default_base_for_merges) return nothing;
+    return html`
+      <section>
+        <span class="title">Default Base For Merges</span>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(
+              this.localPrefs.default_base_for_merges
+            )}
+            @change=${() => {
+              this.localPrefs.default_base_for_merges = this
+                .defaultBaseForMergesSelect.value as DefaultBase;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="defaultBaseForMergesSelect">
+              <option value="AUTO_MERGE">Auto Merge</option>
+              <option value="FIRST_PARENT">First Parent</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRelativeDateInChangeTable() {
+    return html`
+      <section>
+        <label class="title" for="relativeDateInChangeTable"
+          >Show Relative Dates In Changes Table</label
+        >
+        <span class="value">
+          <input
+            id="relativeDateInChangeTable"
+            type="checkbox"
+            ?checked=${this.localPrefs.relative_date_in_change_table}
+            @change=${() => {
+              this.localPrefs.relative_date_in_change_table =
+                this.relativeDateInChangeTable.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDiffView() {
+    return html`
+      <section>
+        <span class="title">Diff view</span>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.diff_view)}
+            @change=${() => {
+              this.localPrefs.diff_view = this.diffViewSelect
+                .value as DiffViewMode;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="diffViewSelect">
+              <option value="SIDE_BY_SIDE">Side by side</option>
+              <option value="UNIFIED_DIFF">Unified diff</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderShowSizeBarsInFileList() {
+    return html`
+      <section>
+        <label for="showSizeBarsInFileList" class="title"
+          >Show size bars in file list</label
+        >
+        <span class="value">
+          <input
+            id="showSizeBarsInFileList"
+            type="checkbox"
+            ?checked=${this.localPrefs.size_bar_in_change_table}
+            @change=${() => {
+              this.localPrefs.size_bar_in_change_table =
+                this.showSizeBarsInFileList.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPublishCommentsOnPush() {
+    return html`
+      <section>
+        <label for="publishCommentsOnPush" class="title"
+          >Publish comments on push</label
+        >
+        <span class="value">
+          <input
+            id="publishCommentsOnPush"
+            type="checkbox"
+            ?checked=${this.localPrefs.publish_comments_on_push}
+            @change=${() => {
+              this.localPrefs.publish_comments_on_push =
+                this.publishCommentsOnPush.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <label for="workInProgressByDefault" class="title"
+          >Set new changes to "work in progress" by default</label
+        >
+        <span class="value">
+          <input
+            id="workInProgressByDefault"
+            type="checkbox"
+            ?checked=${this.localPrefs.work_in_progress_by_default}
+            @change=${() => {
+              this.localPrefs.work_in_progress_by_default =
+                this.workInProgressByDefault.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableKeyboardShortcuts() {
+    return html`
+      <section>
+        <label for="disableKeyboardShortcuts" class="title"
+          >Disable all keyboard shortcuts</label
+        >
+        <span class="value">
+          <input
+            id="disableKeyboardShortcuts"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_keyboard_shortcuts}
+            @change=${() => {
+              this.localPrefs.disable_keyboard_shortcuts =
+                this.disableKeyboardShortcuts.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableTokenHighlighting() {
+    return html`
+      <section>
+        <label for="disableTokenHighlighting" class="title"
+          >Disable token highlighting on hover</label
+        >
+        <span class="value">
+          <input
+            id="disableTokenHighlighting"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_token_highlighting}
+            @change=${() => {
+              this.localPrefs.disable_token_highlighting =
+                this.disableTokenHighlighting.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderInsertSignedOff() {
+    return html`
+      <section>
+        <label for="insertSignedOff" class="title">
+          Insert Signed-off-by Footer For Inline Edit Changes
+        </label>
+        <span class="value">
+          <input
+            id="insertSignedOff"
+            type="checkbox"
+            ?checked=${this.localPrefs.signed_off_by}
+            @change=${() => {
+              this.localPrefs.signed_off_by = this.insertSignedOff.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
@@ -334,192 +1061,82 @@
   };
 
   reloadAccountDetail() {
-    Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+    Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _copyPrefs(direction: CopyPrefsDirection) {
-    let to;
-    let from;
+  private copyPrefs(direction: CopyPrefsDirection) {
     if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
-      from = this._localPrefs;
-      to = 'prefs';
+      this.prefs = {
+        ...this.localPrefs,
+      };
     } else {
-      from = this.prefs;
-      to = '_localPrefs';
-    }
-    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
-      this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+      this.localPrefs = {
+        ...this.prefs,
+      };
     }
   }
 
-  _cloneMenu(prefs: TopMenuItemInfo[]) {
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    return prefs.map(({id, ...item}) => item);
-  }
-
-  @observe('_localChangeTableColumns', '_showNumber')
-  _handleChangeTableChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._changeTableChanged = true;
-  }
-
-  @observe('_localPrefs.*')
-  _handlePrefsChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._prefsChanged = true;
-  }
-
-  _handleRelativeDateInChangeTable() {
-    this.set(
-      '_localPrefs.relative_date_in_change_table',
-      this.$.relativeDateInChangeTable.checked
-    );
-  }
-
-  _handleShowSizeBarsInFileListChanged() {
-    this.set(
-      '_localPrefs.size_bar_in_change_table',
-      this.$.showSizeBarsInFileList.checked
-    );
-  }
-
-  _handlePublishCommentsOnPushChanged() {
-    this.set(
-      '_localPrefs.publish_comments_on_push',
-      this.$.publishCommentsOnPush.checked
-    );
-  }
-
-  _handleDisableKeyboardShortcutsChanged() {
-    this.set(
-      '_localPrefs.disable_keyboard_shortcuts',
-      this.$.disableKeyboardShortcuts.checked
-    );
-  }
-
-  _handleDisableTokenHighlightingChanged() {
-    this.set(
-      '_localPrefs.disable_token_highlighting',
-      this.$.disableTokenHighlighting.checked
-    );
-  }
-
-  _handleWorkInProgressByDefault() {
-    this.set(
-      '_localPrefs.work_in_progress_by_default',
-      this.$.workInProgressByDefault.checked
-    );
-  }
-
-  _handleInsertSignedOff() {
-    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-  }
-
-  @observe('_localMenu.splices')
-  _handleMenuChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._menuChanged = true;
-  }
-
-  _handleSaveAccountInfo() {
-    this.$.accountInfo.save();
-  }
-
-  _handleSavePreferences() {
-    this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+  // private but used in test
+  handleSavePreferences() {
+    this.copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
 
     return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._prefsChanged = false;
+      this.prefsChanged = false;
     });
   }
 
-  _handleSaveChangeTable() {
-    this.set('prefs.change_table', this._localChangeTableColumns);
-    this.set('prefs.legacycid_in_change_table', this._showNumber);
+  // private but used in test
+  handleSaveChangeTable() {
+    this.prefs.change_table = this.localChangeTableColumns;
+    this.prefs.legacycid_in_change_table = this.showNumber;
     return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._changeTableChanged = false;
+      this.changeTableChanged = false;
     });
   }
 
-  _handleSaveDiffPreferences() {
-    this.$.diffPrefs.save();
-  }
-
-  _handleSaveEditPreferences() {
-    this.$.editPrefs.save();
-  }
-
-  _handleSaveMenu() {
-    this.set('prefs.my', this._localMenu);
-    return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._menuChanged = false;
-    });
-  }
-
-  _handleResetMenuButton() {
-    return this.restApiService.getDefaultPreferences().then(data => {
-      if (data?.my) {
-        this._localMenu = this._cloneMenu(data.my);
-      }
-    });
-  }
-
-  _handleSaveWatchedProjects() {
-    this.$.watchedProjectsEditor.save();
-  }
-
-  _computeHeaderClass(changed?: boolean) {
+  private computeHeaderClass(changed?: boolean) {
     return changed ? 'edited' : '';
   }
 
-  _handleSaveEmails() {
-    this.$.emailEditor.save();
-  }
-
-  _handleNewEmailKeydown(e: KeyboardEvent) {
+  // private but used in test
+  handleNewEmailKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter
       e.stopPropagation();
-      this._handleAddEmailButton();
+      this.handleAddEmailButton();
     }
   }
 
-  _isNewEmailValid(newEmail?: string): newEmail is string {
+  // private but used in test
+  isNewEmailValid(newEmail?: string): newEmail is string {
     return !!newEmail && newEmail.includes('@');
   }
 
-  _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
-    return this._isNewEmailValid(newEmail) && !addingEmail;
+  // private but used in test
+  computeAddEmailButtonEnabled() {
+    return this.isNewEmailValid(this.newEmail) && !this.addingEmail;
   }
 
-  _handleAddEmailButton() {
-    if (!this._isNewEmailValid(this._newEmail)) return;
+  // private but used in test
+  handleAddEmailButton() {
+    if (!this.isNewEmailValid(this.newEmail)) return;
 
-    this._addingEmail = true;
-    this.restApiService.addAccountEmail(this._newEmail).then(response => {
-      this._addingEmail = false;
+    this.addingEmail = true;
+    this.restApiService.addAccountEmail(this.newEmail).then(response => {
+      this.addingEmail = false;
 
       // If it was unsuccessful.
       if (response.status < 200 || response.status >= 300) {
         return;
       }
 
-      this._lastSentVerificationEmail = this._newEmail;
-      this._newEmail = '';
+      this.lastSentVerificationEmail = this.newEmail;
+      this.newEmail = '';
     });
   }
 
-  _getFilterDocsLink(docsBaseUrl?: string | null) {
+  // private but used in test
+  getFilterDocsLink(docsBaseUrl?: string | null) {
     let base = docsBaseUrl;
     if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
       base = GERRIT_DOCS_BASE_URL;
@@ -531,8 +1148,8 @@
     return base + GERRIT_DOCS_FILTER_PATH;
   }
 
-  _handleToggleDark() {
-    if (this._isDark) {
+  private handleToggleDark() {
+    if (this.isDark) {
       window.localStorage.removeItem('dark-theme');
     } else {
       window.localStorage.setItem('dark-theme', 'true');
@@ -540,14 +1157,17 @@
     this.reloadPage();
   }
 
+  // private but used in test
   reloadPage() {
+    fireAlert(this, 'Reloading...');
     windowLocationReload();
   }
 
-  _showHttpAuth(config?: ServerInfo) {
-    if (config && config.auth && config.auth.git_basic_auth_policy) {
+  // private but used in test
+  showHttpAuth() {
+    if (this.serverConfig?.auth?.git_basic_auth_policy) {
       return HTTP_AUTH.includes(
-        config.auth.git_basic_auth_policy.toUpperCase()
+        this.serverConfig.auth.git_basic_auth_policy.toUpperCase()
       );
     }
 
@@ -557,57 +1177,17 @@
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapDarkToggle(e: Event) {
+  private onTapDarkToggle(e: Event) {
     e.preventDefault();
   }
 
-  _handleChangesPerPage() {
-    this.set(
-      '_localPrefs.changes_per_page',
-      Number(this.$.changesPerPageSelect.value)
-    );
-  }
-
-  _handleDateFormat() {
-    this.set('_localPrefs.date_format', this.$.dateTimeFormatSelect.value);
-  }
-
-  _handleTimeFormat() {
-    this.set('_localPrefs.time_format', this.$.timeFormatSelect.value);
-  }
-
-  _handleEmailStrategy() {
-    this.set(
-      '_localPrefs.email_strategy',
-      this.$.emailNotificationsSelect.value
-    );
-  }
-
-  _handleEmailFormat() {
-    this.set('_localPrefs.email_format', this.$.emailFormatSelect.value);
-  }
-
-  _handleDefaultBaseForMerges() {
-    this.set(
-      '_localPrefs.default_base_for_merges',
-      this.$.defaultBaseForMergesSelect.value
-    );
-  }
-
-  _handleDiffView() {
-    this.set(
-      '_localPrefs.diff_view',
-      this.$.diffViewSelect.value as DiffViewMode
-    );
-  }
-
   /**
    * bind-value has type string so we have to convert anything inputed
    * to string.
    *
    * This is so typescript template checker doesn't fail.
    */
-  _convertToString(
+  private convertToString(
     key?:
       | DateFormat
       | DefaultBase
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
deleted file mode 100644
index c1ebcac..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ /dev/null
@@ -1,614 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      color: var(--primary-text-color);
-    }
-    h2 {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h2);
-      font-weight: var(--font-weight-h2);
-      line-height: var(--line-height-h2);
-    }
-    .newEmailInput {
-      width: 20em;
-    }
-    #email {
-      margin-bottom: var(--spacing-l);
-    }
-    .main section.darkToggle {
-      display: block;
-    }
-    .filters p,
-    .darkToggle p {
-      margin-bottom: var(--spacing-l);
-    }
-    .queryExample em {
-      color: violet;
-    }
-    .toggle {
-      align-items: center;
-      display: flex;
-      margin-bottom: var(--spacing-l);
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-page-nav class="navStyles">
-      <ul>
-        <li><a href="#Profile">Profile</a></li>
-        <li><a href="#Preferences">Preferences</a></li>
-        <li><a href="#DiffPreferences">Diff Preferences</a></li>
-        <li><a href="#EditPreferences">Edit Preferences</a></li>
-        <li><a href="#Menu">Menu</a></li>
-        <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
-        <li><a href="#Notifications">Notifications</a></li>
-        <li><a href="#EmailAddresses">Email Addresses</a></li>
-        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
-        </template>
-        <li hidden$="[[!_serverConfig.sshd]]">
-          <a href="#SSHKeys"> SSH Keys </a>
-        </li>
-        <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <a href="#GPGKeys"> GPG Keys </a>
-        </li>
-        <li><a href="#Groups">Groups</a></li>
-        <li><a href="#Identities">Identities</a></li>
-        <template
-          is="dom-if"
-          if="[[_serverConfig.auth.use_contributor_agreements]]"
-        >
-          <li>
-            <a href="#Agreements">Agreements</a>
-          </li>
-        </template>
-        <li><a href="#MailFilters">Mail Filters</a></li>
-        <gr-endpoint-decorator name="settings-menu-item">
-        </gr-endpoint-decorator>
-      </ul>
-    </gr-page-nav>
-    <div class="main gr-form-styles">
-      <h1 class="heading-1">User Settings</h1>
-      <h2 id="Theme">Theme</h2>
-      <section class="darkToggle">
-        <div class="toggle">
-          <paper-toggle-button
-            aria-labelledby="darkThemeToggleLabel"
-            checked="[[_isDark]]"
-            on-change="_handleToggleDark"
-            on-click="_onTapDarkToggle"
-          ></paper-toggle-button>
-          <div id="darkThemeToggleLabel">
-            Dark theme (the toggle reloads the page)
-          </div>
-        </div>
-      </section>
-      <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
-        Profile
-      </h2>
-      <fieldset id="profile">
-        <gr-account-info
-          id="accountInfo"
-          has-unsaved-changes="{{_accountInfoChanged}}"
-        ></gr-account-info>
-        <gr-button
-          on-click="_handleSaveAccountInfo"
-          disabled="[[!_accountInfoChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
-        Preferences
-      </h2>
-      <fieldset id="preferences">
-        <section>
-          <label class="title" for="changesPerPageSelect"
-            >Changes per page</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.changes_per_page)]]"
-              on-change="_handleChangesPerPage"
-            >
-              <select id="changesPerPageSelect">
-                <option value="10">10 rows per page</option>
-                <option value="25">25 rows per page</option>
-                <option value="50">50 rows per page</option>
-                <option value="100">100 rows per page</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="dateTimeFormatSelect"
-            >Date/time format</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.date_format)]]"
-              on-change="_handleDateFormat"
-            >
-              <select id="dateTimeFormatSelect">
-                <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                <option value="US">06/03 ; 06/03/16</option>
-                <option value="ISO">06-03 ; 2016-06-03</option>
-                <option value="EURO">3. Jun ; 03.06.2016</option>
-                <option value="UK">03/06 ; 03/06/2016</option>
-              </select>
-            </gr-select>
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.time_format)]]"
-              aria-label="Time Format"
-              on-change="_handleTimeFormat"
-            >
-              <select id="timeFormatSelect">
-                <option value="HHMM_12">4:10 PM</option>
-                <option value="HHMM_24">16:10</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="emailNotificationsSelect"
-            >Email notifications</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_strategy)]]"
-              on-change="_handleEmailStrategy"
-            >
-              <select id="emailNotificationsSelect">
-                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                <option value="ENABLED">Only comments left by others</option>
-                <option value="ATTENTION_SET_ONLY">
-                  Only when I am in the attention set
-                </option>
-                <option value="DISABLED">None</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_convertToString(_localPrefs.email_format)]]">
-          <label class="title" for="emailFormatSelect">Email format</label>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_format)]]"
-              on-change="_handleEmailFormat"
-            >
-              <select id="emailFormatSelect">
-                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                <option value="PLAINTEXT">Plaintext only</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_localPrefs.default_base_for_merges]]">
-          <span class="title">Default Base For Merges</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.default_base_for_merges)]]"
-              on-change="_handleDefaultBaseForMerges"
-            >
-              <select id="defaultBaseForMergesSelect">
-                <option value="AUTO_MERGE">Auto Merge</option>
-                <option value="FIRST_PARENT">First Parent</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="relativeDateInChangeTable"
-            >Show Relative Dates In Changes Table</label
-          >
-          <span class="value">
-            <input
-              id="relativeDateInChangeTable"
-              type="checkbox"
-              checked$="[[_localPrefs.relative_date_in_change_table]]"
-              on-change="_handleRelativeDateInChangeTable"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title">Diff view</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.diff_view)]]"
-              on-change="_handleDiffView"
-            >
-              <select id="diffViewSelect">
-                <option value="SIDE_BY_SIDE">Side by side</option>
-                <option value="UNIFIED_DIFF">Unified diff</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label for="showSizeBarsInFileList" class="title"
-            >Show size bars in file list</label
-          >
-          <span class="value">
-            <input
-              id="showSizeBarsInFileList"
-              type="checkbox"
-              checked$="[[_localPrefs.size_bar_in_change_table]]"
-              on-change="_handleShowSizeBarsInFileListChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="publishCommentsOnPush" class="title"
-            >Publish comments on push</label
-          >
-          <span class="value">
-            <input
-              id="publishCommentsOnPush"
-              type="checkbox"
-              checked$="[[_localPrefs.publish_comments_on_push]]"
-              on-change="_handlePublishCommentsOnPushChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="workInProgressByDefault" class="title"
-            >Set new changes to "work in progress" by default</label
-          >
-          <span class="value">
-            <input
-              id="workInProgressByDefault"
-              type="checkbox"
-              checked$="[[_localPrefs.work_in_progress_by_default]]"
-              on-change="_handleWorkInProgressByDefault"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableKeyboardShortcuts" class="title"
-            >Disable all keyboard shortcuts</label
-          >
-          <span class="value">
-            <input
-              id="disableKeyboardShortcuts"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
-              on-change="_handleDisableKeyboardShortcutsChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableTokenHighlighting" class="title"
-            >Disable token highlighting on hover</label
-          >
-          <span class="value">
-            <input
-              id="disableTokenHighlighting"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_token_highlighting]]"
-              on-change="_handleDisableTokenHighlightingChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="insertSignedOff" class="title">
-            Insert Signed-off-by Footer For Inline Edit Changes
-          </label>
-          <span class="value">
-            <input
-              id="insertSignedOff"
-              type="checkbox"
-              checked$="[[_localPrefs.signed_off_by]]"
-              on-change="_handleInsertSignedOff"
-            />
-          </span>
-        </section>
-        <gr-button
-          id="savePrefs"
-          on-click="_handleSavePreferences"
-          disabled="[[!_prefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="DiffPreferences"
-        class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
-      >
-        Diff Preferences
-      </h2>
-      <fieldset id="diffPreferences">
-        <gr-diff-preferences
-          id="diffPrefs"
-          has-unsaved-changes="{{_diffPrefsChanged}}"
-        ></gr-diff-preferences>
-        <gr-button
-          id="saveDiffPrefs"
-          on-click="_handleSaveDiffPreferences"
-          disabled$="[[!_diffPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="EditPreferences"
-        class$="[[_computeHeaderClass(_editPrefsChanged)]]"
-      >
-        Edit Preferences
-      </h2>
-      <fieldset id="editPreferences">
-        <gr-edit-preferences
-          id="editPrefs"
-          has-unsaved-changes="{{_editPrefsChanged}}"
-        ></gr-edit-preferences>
-        <gr-button
-          id="saveEditPrefs"
-          on-click="_handleSaveEditPreferences"
-          disabled$="[[!_editPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
-      <fieldset id="menu">
-        <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
-        <gr-button
-          id="saveMenu"
-          on-click="_handleSaveMenu"
-          disabled="[[!_menuChanged]]"
-          >Save changes</gr-button
-        >
-        <gr-button id="resetButton" link="" on-click="_handleResetMenuButton"
-          >Reset</gr-button
-        >
-      </fieldset>
-      <h2
-        id="ChangeTableColumns"
-        class$="[[_computeHeaderClass(_changeTableChanged)]]"
-      >
-        Change Table Columns
-      </h2>
-      <fieldset id="changeTableColumns">
-        <gr-change-table-editor
-          show-number="{{_showNumber}}"
-          server-config="[[_serverConfig]]"
-          displayed-columns="{{_localChangeTableColumns}}"
-        >
-        </gr-change-table-editor>
-        <gr-button
-          id="saveChangeTable"
-          on-click="_handleSaveChangeTable"
-          disabled="[[!_changeTableChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="Notifications"
-        class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
-      >
-        Notifications
-      </h2>
-      <fieldset id="watchedProjects">
-        <gr-watched-projects-editor
-          has-unsaved-changes="{{_watchedProjectsChanged}}"
-          id="watchedProjectsEditor"
-        ></gr-watched-projects-editor>
-        <gr-button
-          on-click="_handleSaveWatchedProjects"
-          disabled$="[[!_watchedProjectsChanged]]"
-          id="_handleSaveWatchedProjects"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
-        Email Addresses
-      </h2>
-      <fieldset id="email">
-        <gr-email-editor
-          id="emailEditor"
-          has-unsaved-changes="{{_emailsChanged}}"
-        ></gr-email-editor>
-        <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <fieldset id="newEmail">
-        <section>
-          <span class="title">New email address</span>
-          <span class="value">
-            <iron-input
-              class="newEmailInput"
-              bind-value="{{_newEmail}}"
-              type="text"
-              on-keydown="_handleNewEmailKeydown"
-              placeholder="email@example.com"
-            >
-              <input
-                class="newEmailInput"
-                type="text"
-                disabled="[[_addingEmail]]"
-                on-keydown="_handleNewEmailKeydown"
-                placeholder="email@example.com"
-              />
-            </iron-input>
-          </span>
-        </section>
-        <section
-          id="verificationSentMessage"
-          hidden$="[[!_lastSentVerificationEmail]]"
-        >
-          <p>
-            A verification email was sent to
-            <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
-          </p>
-        </section>
-        <gr-button
-          disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-          on-click="_handleAddEmailButton"
-          >Send verification</gr-button
-        >
-      </fieldset>
-      <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-        <div>
-          <h2 id="HTTPCredentials">HTTP Credentials</h2>
-          <fieldset>
-            <gr-http-password id="httpPass"></gr-http-password>
-          </fieldset>
-        </div>
-      </template>
-      <div hidden$="[[!_serverConfig.sshd]]">
-        <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
-          SSH keys
-        </h2>
-        <gr-ssh-editor
-          id="sshEditor"
-          has-unsaved-changes="{{_keysChanged}}"
-        ></gr-ssh-editor>
-      </div>
-      <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-        <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
-          GPG keys
-        </h2>
-        <gr-gpg-editor
-          id="gpgEditor"
-          has-unsaved-changes="{{_gpgKeysChanged}}"
-        ></gr-gpg-editor>
-      </div>
-      <h2 id="Groups">Groups</h2>
-      <fieldset>
-        <gr-group-list id="groupList"></gr-group-list>
-      </fieldset>
-      <h2 id="Identities">Identities</h2>
-      <fieldset>
-        <gr-identities
-          id="identities"
-          server-config="[[_serverConfig]]"
-        ></gr-identities>
-      </fieldset>
-      <template
-        is="dom-if"
-        if="[[_serverConfig.auth.use_contributor_agreements]]"
-      >
-        <h2 id="Agreements">Agreements</h2>
-        <fieldset>
-          <gr-agreements-list id="agreementsList"></gr-agreements-list>
-        </fieldset>
-      </template>
-      <h2 id="MailFilters">Mail Filters</h2>
-      <fieldset class="filters">
-        <p>
-          Gerrit emails include metadata about the change to support writing
-          mail filters.
-        </p>
-        <p>
-          Here are some example Gmail queries that can be used for filters or
-          for searching through archived messages. View the
-          <a
-            href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
-            target="_blank"
-            rel="nofollow"
-            >Gerrit documentation</a
-          >
-          for the complete set of footers.
-        </p>
-        <table>
-          <tbody>
-            <tr>
-              <th>Name</th>
-              <th>Query</th>
-            </tr>
-            <tr>
-              <td>Changes requesting my review</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Reviewer: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes requesting my attention</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Attention: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes from a specific owner</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Owner: <em>Owner name</em>
-                  &lt;<em>owner.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes targeting a specific branch</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Branch: <em>branch-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes in a specific project</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Project: <em>project-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific Change ID</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Id: <em>Change ID</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific change number</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Number: <em>change number</em>"
-                </code>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </fieldset>
-      <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 61876fe..ae42619 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma';
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GerritView} from '../../../services/router/router-model';
 import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
@@ -57,7 +56,7 @@
   let config: ServerInfo;
 
   function valueOf(title: string, id: string) {
-    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    const sections = queryAll(element, `#${id} section`);
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -123,10 +122,420 @@
     stubRestApi('getAccountEmails').returns(Promise.resolve(undefined));
     stubRestApi('getConfig').returns(Promise.resolve(config));
     element = basicFixture.instantiate();
+    await element.updateComplete;
 
     // Allow the element to render.
     if (element._testOnly_loadingPromise)
       await element._testOnly_loadingPromise;
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    sinon
+      .stub(element, 'getFilterDocsLink')
+      .returns('https://test.com/user-notify.html');
+    element.docsBaseUrl = 'https://test.com';
+    await element.updateComplete;
+    // this cannot be formatted with /* HTML */, because it breaks test
+    expect(element).shadowDom.to.equal(/* HTML*/ `<div
+        class="loading"
+        hidden=""
+      >
+        Loading...
+      </div>
+      <div>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile"> Profile </a></li>
+            <li><a href="#Preferences"> Preferences </a></li>
+            <li><a href="#DiffPreferences"> Diff Preferences </a></li>
+            <li><a href="#EditPreferences"> Edit Preferences </a></li>
+            <li><a href="#Menu"> Menu </a></li>
+            <li><a href="#ChangeTableColumns"> Change Table Columns </a></li>
+            <li><a href="#Notifications"> Notifications </a></li>
+            <li><a href="#EmailAddresses"> Email Addresses </a></li>
+            <li><a href="#Groups"> Groups </a></li>
+            <li><a href="#Identities"> Identities </a></li>
+            <li><a href="#MailFilters"> Mail Filters </a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="gr-form-styles main">
+          <h1 class="heading-1">User Settings</h1>
+          <h2 id="Theme">Theme</h2>
+          <section class="darkToggle">
+            <div class="toggle">
+              <paper-toggle-button
+                aria-disabled="false"
+                aria-labelledby="darkThemeToggleLabel"
+                aria-pressed="false"
+                role="button"
+                style="touch-action: none;"
+                tabindex="0"
+                toggles=""
+              >
+              </paper-toggle-button>
+              <div id="darkThemeToggleLabel">
+                Dark theme
+              </div>
+            </div>
+          </section>
+          <h2 id="Profile">Profile</h2>
+          <fieldset id="profile">
+            <gr-account-info id="accountInfo"> </gr-account-info>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Preferences">Preferences</h2>
+          <fieldset id="preferences">
+            <section>
+              <label class="title" for="changesPerPageSelect">
+                Changes per page
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="changesPerPageSelect">
+                    <option value="10">10 rows per page</option>
+                    <option value="25">25 rows per page</option>
+                    <option value="50">50 rows per page</option>
+                    <option value="100">100 rows per page</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="dateTimeFormatSelect">
+                Date/time format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="dateTimeFormatSelect">
+                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                    <option value="US">06/03 ; 06/03/16</option>
+                    <option value="ISO">06-03 ; 2016-06-03</option>
+                    <option value="EURO">3. Jun ; 03.06.2016</option>
+                    <option value="UK">03/06 ; 03/06/2016</option>
+                  </select>
+                </gr-select>
+                <gr-select aria-label="Time Format">
+                  <select id="timeFormatSelect">
+                    <option value="HHMM_12">4:10 PM</option>
+                    <option value="HHMM_24">16:10</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailNotificationsSelect">
+                Email notifications
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailNotificationsSelect">
+                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                    <option value="ENABLED">
+                      Only comments left by others
+                    </option>
+                    <option value="ATTENTION_SET_ONLY">
+                      Only when I am in the attention set
+                    </option>
+                    <option value="DISABLED">None</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailFormatSelect">
+                Email format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailFormatSelect">
+                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                    <option value="PLAINTEXT">Plaintext only</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Default Base For Merges </span>
+              <span class="value">
+                <gr-select>
+                  <select id="defaultBaseForMergesSelect">
+                    <option value="AUTO_MERGE">Auto Merge</option>
+                    <option value="FIRST_PARENT">First Parent</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="relativeDateInChangeTable">
+                Show Relative Dates In Changes Table
+              </label>
+              <span class="value">
+                <input id="relativeDateInChangeTable" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <span class="title"> Diff view </span>
+              <span class="value">
+                <gr-select>
+                  <select id="diffViewSelect">
+                    <option value="SIDE_BY_SIDE">Side by side</option>
+                    <option value="UNIFIED_DIFF">Unified diff</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showSizeBarsInFileList">
+                Show size bars in file list
+              </label>
+              <span class="value">
+                <input
+                  checked=""
+                  id="showSizeBarsInFileList"
+                  type="checkbox"
+                />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="publishCommentsOnPush">
+                Publish comments on push
+              </label>
+              <span class="value">
+                <input id="publishCommentsOnPush" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="workInProgressByDefault">
+                Set new changes to "work in progress" by default
+              </label>
+              <span class="value">
+                <input id="workInProgressByDefault" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableKeyboardShortcuts">
+                Disable all keyboard shortcuts
+              </label>
+              <span class="value">
+                <input id="disableKeyboardShortcuts" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableTokenHighlighting">
+                Disable token highlighting on hover
+              </label>
+              <span class="value">
+                <input id="disableTokenHighlighting" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="insertSignedOff">
+                Insert Signed-off-by Footer For Inline Edit Changes
+              </label>
+              <span class="value">
+                <input id="insertSignedOff" type="checkbox" />
+              </span>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="savePrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="DiffPreferences">Diff Preferences</h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveDiffPrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <gr-edit-preferences id="editPrefs"> </gr-edit-preferences>
+          <gr-menu-editor> </gr-menu-editor>
+          <h2 id="ChangeTableColumns">Change Table Columns</h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor> </gr-change-table-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveChangeTable"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Notifications">Notifications</h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor id="watchedProjectsEditor">
+            </gr-watched-projects-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="_handleSaveWatchedProjects"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="EmailAddresses">Email Addresses</h2>
+          <fieldset id="email">
+            <gr-email-editor id="emailEditor"> </gr-email-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title"> New email address </span>
+              <span class="value">
+                <iron-input class="newEmailInput">
+                  <input
+                    class="newEmailInput"
+                    placeholder="email@example.com"
+                    type="text"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section hidden="" id="verificationSentMessage">
+              <p>
+                A verification email was sent to <em>
+                </em>
+               . Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Send verification
+            </gr-button>
+          </fieldset> 
+          <h2 id="Groups">Groups</h2>
+          <fieldset><gr-group-list id="groupList"> </gr-group-list></fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities id="identities"> </gr-identities>
+          </fieldset>
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href="https://test.com/user-notify.html"
+                rel="nofollow"
+                target="_blank"
+              >
+                Gerrit documentation
+              </a>
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em> Owner name </em> <
+                      <em> owner.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em> branch-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em> project-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em> Change ID </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em> change number </em> "
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`);
   });
 
   test('theme changing', async () => {
@@ -142,7 +551,7 @@
     assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
     assert.isTrue(reloadStub.calledOnce);
 
-    element._isDark = true;
+    element.isDark = true;
     await flush();
     MockInteractions.tap(themeToggle);
     assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
@@ -259,16 +668,14 @@
       false
     );
 
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    assert.isFalse(element.prefsChanged);
 
     const publishOnPush = valueOf('Publish comments on push', 'preferences')!
       .firstElementChild!;
 
     MockInteractions.tap(publishOnPush);
 
-    assert.isTrue(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assertMenusEqual(prefs.my, preferences.my);
@@ -277,9 +684,8 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('publish comments on push', async () => {
@@ -289,8 +695,7 @@
     )!.firstElementChild!;
     MockInteractions.tap(publishCommentsOnPush);
 
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.publish_comments_on_push, true);
@@ -298,9 +703,8 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('set new changes work-in-progress', async () => {
@@ -310,8 +714,7 @@
     )!.firstElementChild!;
     MockInteractions.tap(newChangesWorkInProgress);
 
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.work_in_progress_by_default, true);
@@ -319,71 +722,40 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
-  test('menu', async () => {
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
+  test('add email validation', async () => {
+    assert.isFalse(element.isNewEmailValid('invalid email'));
+    assert.isTrue(element.isNewEmailValid('vaguely@valid.email'));
 
-    assertMenusEqual(element._localMenu, preferences.my);
-
-    const menu = element.$.menu.firstElementChild!;
-    let tableRows = queryAll(menu, 'tbody tr');
-    // let tableRows = menu.root.querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length);
-
-    // Add a menu item:
-    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-    flush();
-
-    // tableRows = menu.root.querySelectorAll('tbody tr');
-    tableRows = queryAll(menu, 'tbody tr');
-    assert.equal(tableRows.length, preferences.my.length + 1);
-
-    assert.isTrue(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assertMenusEqual(prefs.my, element._localMenu);
-      return Promise.resolve(createDefaultPreferences());
-    });
-
-    await element._handleSaveMenu();
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-    assertMenusEqual(element.prefs.my, element._localMenu);
-  });
-
-  test('add email validation', () => {
-    assert.isFalse(element._isNewEmailValid('invalid email'));
-    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
-
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('invalid email', true)
-    );
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', true)
-    );
-    assert.isTrue(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', false)
-    );
+    element.newEmail = 'invalid email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = false;
+    await element.updateComplete;
+    assert.isTrue(element.computeAddEmailButtonEnabled());
   });
 
   test('add email does not save invalid', () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'invalid email';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'invalid email';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isFalse(element._addingEmail);
+    assert.isFalse(element.addingEmail);
     assert.isFalse(addEmailStub.called);
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
 
     assert.isFalse(addEmailStub.called);
   });
@@ -391,95 +763,59 @@
   test('add email does save valid', async () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isTrue(element._addingEmail);
+    assert.isTrue(element.addingEmail);
     assert.isTrue(addEmailStub.called);
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isOk(element._lastSentVerificationEmail);
+    assert.isOk(element.lastSentVerificationEmail);
   });
 
   test('add email does not set last-email if error', async () => {
     const addEmailStub = stubAddAccountEmail(500);
 
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
   });
 
   test('emails are loaded without emailToken', () => {
-    const emailEditorLoadDataStub = sinon.stub(
-      element.$.emailEditor,
-      'loadData'
-    );
+    const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
     element.params = {
       view: GerritView.SETTINGS,
     } as AppElementSettingsParam;
-    element.connectedCallback();
+    element.firstUpdated();
     assert.isTrue(emailEditorLoadDataStub.calledOnce);
   });
 
-  test('_handleSaveChangeTable', () => {
+  test('handleSaveChangeTable', () => {
     let newColumns = ['Owner', 'Project', 'Branch'];
-    element._localChangeTableColumns = newColumns.slice(0);
-    element._showNumber = false;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns.slice(0);
+    element.showNumber = false;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isNotOk(element.prefs.legacycid_in_change_table);
 
     newColumns = ['Size'];
-    element._localChangeTableColumns = newColumns;
-    element._showNumber = true;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns;
+    element.showNumber = true;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
 
-  test('reset menu item back to default', async () => {
-    const originalMenu = {
-      ...createDefaultPreferences(),
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ] as TopMenuItemInfo[],
-    };
-
-    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
-
-    const updatedMenu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-    ];
-
-    element.set('_localMenu', updatedMenu);
-
-    await element._handleResetMenuButton();
-    assertMenusEqual(element._localMenu, originalMenu.my);
-  });
-
-  test('test that reset button is called', () => {
-    const overlayOpen = sinon.stub(element, '_handleResetMenuButton');
-
-    MockInteractions.tap(element.$.resetButton);
-
-    assert.isTrue(overlayOpen.called);
-  });
-
-  test('_showHttpAuth', () => {
+  test('showHttpAuth', async () => {
     const serverConfig: ServerInfo = {
       ...createServerInfo(),
       auth: {
@@ -487,41 +823,48 @@
       } as AuthInfo,
     };
 
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig = serverConfig;
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'LDAP';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'LDAP';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'OAUTH';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'OAUTH';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    assert.isFalse(element._showHttpAuth(undefined));
+    element.serverConfig = undefined;
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
   });
 
-  suite('_getFilterDocsLink', () => {
+  suite('getFilterDocsLink', () => {
     test('with http: docs base URL', () => {
       const base = 'http://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with http: docs base URL without slash', () => {
       const base = 'http://example.com';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with https: docs base URL', () => {
       const base = 'https://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'https://example.com/user-notify.html');
     });
 
     test('without docs base URL', () => {
-      const result = element._getFilterDocsLink(null);
+      const result = element.getFilterDocsLink(null);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -531,7 +874,7 @@
 
     test('ignores non HTTP links', () => {
       const base = 'javascript://alert("evil");';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -548,7 +891,7 @@
     let emailEditorLoadDataStub: sinon.SinonStub;
 
     setup(() => {
-      emailEditorLoadDataStub = sinon.stub(element.$.emailEditor, 'loadData');
+      emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
       confirmEmailStub = stubRestApi('confirmEmail').returns(
         new Promise(resolve => {
           resolveConfirm = resolve;
@@ -556,7 +899,7 @@
       );
 
       element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.connectedCallback();
+      element.firstUpdated();
     });
 
     test('it is used to confirm email via rest API', () => {
@@ -585,7 +928,7 @@
       );
       assert.deepEqual(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).detail,
-        {message: 'bar'}
+        {message: 'bar', showDismiss: true}
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index 95d15b8..72c87b2 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -21,22 +21,19 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-ssh-editor_html';
-import {property, customElement} from '@polymer/decorators';
 import {SshKeyInfo} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
-
-export interface GrSshEditor {
-  $: {
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-    viewKeyOverlay: GrOverlay;
-  };
-}
+import {LitElement, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {PropertyValues} from 'lit';
+import {formStyles} from '../../../styles/gr-form-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,85 +41,236 @@
   }
 }
 @customElement('gr-ssh-editor')
-export class GrSshEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean, notify: true})
+export class GrSshEditor extends LitElement {
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
   @property({type: Array})
-  _keys: SshKeyInfo[] = [];
+  keys: SshKeyInfo[] = [];
 
   @property({type: Object})
-  _keyToView?: SshKeyInfo;
+  keyToView?: SshKeyInfo;
 
   @property({type: String})
-  _newKey = '';
+  newKey = '';
 
   @property({type: Array})
-  _keysToRemove: SshKeyInfo[] = [];
+  keysToRemove: SshKeyInfo[] = [];
+
+  @state() prevHasUnsavedChanges = false;
+
+  @query('#addButton') addButton!: GrButton;
+
+  @query('#newKey') newKeyEditor!: IronAutogrowTextareaElement;
+
+  @query('#viewKeyOverlay') viewKeyOverlay!: GrOverlay;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        .statusHeader {
+          width: 4em;
+        }
+        .keyHeader {
+          width: 7.5em;
+        }
+        #viewKeyOverlay {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        .publicKey {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          overflow-x: scroll;
+          overflow-wrap: break-word;
+          width: 30em;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+        #existing {
+          margin-bottom: var(--spacing-l);
+        }
+        #existing .commentColumn {
+          min-width: 27em;
+          width: auto;
+        }
+      `,
+    ];
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasUnsavedChanges')) {
+      if (this.prevHasUnsavedChanges === this.hasUnsavedChanges) return;
+      this.prevHasUnsavedChanges = this.hasUnsavedChanges;
+      fire(this, 'has-unsaved-changes-changed', {
+        value: this.hasUnsavedChanges,
+      });
+    }
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="commentColumn">Comment</th>
+                <th class="statusHeader">Status</th>
+                <th class="keyHeader">Public key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.keys.map((key, index) => this.renderKey(key, index))}
+            </tbody>
+          </table>
+          <gr-overlay id="viewKeyOverlay" with-backdrop="">
+            <fieldset>
+              <section>
+                <span class="title">Algorithm</span>
+                <span class="value">${this.keyToView?.algorithm}</span>
+              </section>
+              <section>
+                <span class="title">Public key</span>
+                <span class="value publicKey"
+                  >${this.keyToView?.encoded_key}</span
+                >
+              </section>
+              <section>
+                <span class="title">Comment</span>
+                <span class="value">${this.keyToView?.comment}</span>
+              </section>
+            </fieldset>
+            <gr-button
+              class="closeButton"
+              @click=${() => this.viewKeyOverlay.close()}
+              >Close</gr-button
+            >
+          </gr-overlay>
+          <gr-button
+            @click=${() => this.save()}
+            ?disabled=${!this.hasUnsavedChanges}
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">New SSH key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                .bindValue=${this.newKey}
+                placeholder="New SSH Key"
+                @bind-value-changed=${(e: BindValueChangeEvent) => {
+                  this.newKey = e.detail.value;
+                }}
+              ></iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            id="addButton"
+            link=""
+            ?disabled=${!this.newKey.length}
+            @click=${() => this.handleAddKey()}
+            >Add new SSH key</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderKey(key: SshKeyInfo, index: number) {
+    return html` <tr>
+      <td class="commentColumn">${key.comment}</td>
+      <td>${key.valid ? 'Valid' : 'Invalid'}</td>
+      <td>
+        <gr-button
+          link=""
+          @click=${(e: Event) => this.showKey(e)}
+          data-index=${index}
+          >Click to View</gr-button
+        >
+      </td>
+      <td>
+        <gr-copy-clipboard
+          hasTooltip=""
+          .buttonTitle=${'Copy SSH public key to clipboard'}
+          hideInput=""
+          .text=${key.ssh_public_key}
+        >
+        </gr-copy-clipboard>
+      </td>
+      <td>
+        <gr-button
+          link=""
+          data-index=${index}
+          @click=${(e: Event) => this.handleDeleteKey(e)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
   loadData() {
     return this.restApiService.getAccountSSHKeys().then(keys => {
       if (!keys) return;
-      this._keys = keys;
+      this.keys = keys;
     });
   }
 
+  // private but used in tests
   save() {
-    const promises = this._keysToRemove.map(key =>
+    const promises = this.keysToRemove.map(key =>
       this.restApiService.deleteAccountSSHKey(`${key.seq}`)
     );
     return Promise.all(promises).then(() => {
-      this._keysToRemove = [];
+      this.keysToRemove = [];
       this.hasUnsavedChanges = false;
     });
   }
 
-  _getStatusLabel(isValid: boolean) {
-    return isValid ? 'Valid' : 'Invalid';
-  }
-
-  _showKey(e: Event) {
+  private showKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as GrButton;
     const index = Number(el.getAttribute('data-index')!);
-    this._keyToView = this._keys[index];
-    this.$.viewKeyOverlay.open();
+    this.keyToView = this.keys[index];
+    this.viewKeyOverlay.open();
   }
 
-  _closeOverlay() {
-    this.$.viewKeyOverlay.close();
-  }
-
-  _handleDeleteKey(e: Event) {
+  private handleDeleteKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as GrButton;
     const index = Number(el.getAttribute('data-index')!);
-    this.push('_keysToRemove', this._keys[index]);
-    this.splice('_keys', index, 1);
+    this.keysToRemove.push(this.keys[index]);
+    this.keys.splice(index, 1);
+    this.requestUpdate();
     this.hasUnsavedChanges = true;
   }
 
-  _handleAddKey() {
-    this.$.addButton.disabled = true;
-    this.$.newKey.disabled = true;
+  // private but used in tests
+  handleAddKey() {
+    this.addButton.disabled = true;
+    this.newKeyEditor.disabled = true;
     return this.restApiService
-      .addAccountSSHKey(this._newKey.trim())
+      .addAccountSSHKey(this.newKey.trim())
       .then(key => {
-        this.$.newKey.disabled = false;
-        this._newKey = '';
-        this.push('_keys', key);
+        this.newKeyEditor.disabled = false;
+        this.newKey = '';
+        this.keys.push(key);
+        this.requestUpdate();
       })
       .catch(() => {
-        this.$.addButton.disabled = false;
-        this.$.newKey.disabled = false;
+        this.addButton.disabled = false;
+        this.newKeyEditor.disabled = false;
       });
   }
-
-  _computeAddButtonDisabled(newKey: string) {
-    return !newKey.length;
-  }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
deleted file mode 100644
index e853b58..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .statusHeader {
-      width: 4em;
-    }
-    .keyHeader {
-      width: 7.5em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .publicKey {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      overflow-x: scroll;
-      overflow-wrap: break-word;
-      width: 30em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-    #existing .commentColumn {
-      min-width: 27em;
-      width: auto;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="commentColumn">Comment</th>
-            <th class="statusHeader">Status</th>
-            <th class="keyHeader">Public key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="commentColumn">[[key.comment]]</td>
-              <td>[[_getStatusLabel(key.valid)]]</td>
-              <td>
-                <gr-button link="" on-click="_showKey" data-index$="[[index]]"
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  hasTooltip=""
-                  buttonTitle="Copy SSH public key to clipboard"
-                  hideInput=""
-                  text="[[key.ssh_public_key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button
-                  link=""
-                  data-index$="[[index]]"
-                  on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Algorithm</span>
-            <span class="value">[[_keyToView.algorithm]]</span>
-          </section>
-          <section>
-            <span class="title">Public key</span>
-            <span class="value publicKey">[[_keyToView.encoded_key]]</span>
-          </section>
-          <section>
-            <span class="title">Comment</span>
-            <span class="value">[[_keyToView.comment]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New SSH key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New SSH Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        link=""
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new SSH key</gr-button
-      >
-    </fieldset>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
index 6300b76..0f81e9f 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -81,7 +81,7 @@
       Promise.resolve()
     );
 
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
 
     // Get the delete button for the last row.
@@ -92,21 +92,21 @@
 
     MockInteractions.tap(button!);
 
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
+    assert.equal(element.keys.length, 1);
+    assert.equal(element.keysToRemove.length, 1);
+    assert.equal(element.keysToRemove[0], lastKey);
     assert.isTrue(element.hasUnsavedChanges);
     assert.isFalse(saveStub.called);
 
     await element.save();
     assert.isTrue(saveStub.called);
     assert.equal(saveStub.lastCall.args[0], `${lastKey.seq}`);
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
   });
 
   test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+    const openSpy = sinon.spy(element.viewKeyOverlay, 'open');
 
     // Get the show button for the last row.
     const button = query<GrButton>(
@@ -116,7 +116,7 @@
 
     MockInteractions.tap(button!);
 
-    assert.equal(element._keyToView, keys[1]);
+    assert.equal(element.keyToView, keys[1]);
     assert.isTrue(openSpy.called);
   });
 
@@ -133,21 +133,23 @@
 
     const addStub = stubRestApi('addAccountSSHKey').resolves(newKeyObject);
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton.disabled);
+    assert.isFalse(element.newKeyEditor.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 3);
+    element.handleAddKey().then(() => {
+      assert.isTrue(element.addButton.disabled);
+      assert.isFalse(element.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 3);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
 
     assert.isTrue(addStub.called);
     assert.equal(addStub.lastCall.args[0], newKeyString);
@@ -159,21 +161,23 @@
 
     const addStub = stubRestApi('addAccountSSHKey').rejects(new Error('error'));
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton.disabled);
+    assert.isFalse(element.newKeyEditor.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
+    element.handleAddKey().then(() => {
+      assert.isFalse(element.addButton.disabled);
+      assert.isFalse(element.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 2);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
 
     assert.isTrue(addStub.called);
     assert.equal(addStub.lastCall.args[0], newKeyString);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 32ca2c5..e357d2d 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -17,23 +17,25 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-watched-projects-editor_html';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, query} from 'lit/decorators';
 import {
   AutocompleteQuery,
   GrAutocomplete,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {ProjectWatchInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ProjectWatchInfo, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {IronInputElement} from '@polymer/iron-input';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when';
+import {fire} from '../../../utils/event-util';
+import {PropertiesOfType} from '../../../utils/type-util';
 
-const NOTIFICATION_TYPES = [
+type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
+
+const NOTIFICATION_TYPES: Array<{name: string; key: NotificationKey}> = [
   {name: 'Changes', key: 'notify_new_changes'},
   {name: 'Patches', key: 'notify_new_patch_sets'},
   {name: 'Comments', key: 'notify_all_comments'},
@@ -41,50 +43,145 @@
   {name: 'Abandons', key: 'notify_abandoned_changes'},
 ];
 
-export interface GrWatchedProjectsEditor {
-  $: {
-    newFilter: HTMLInputElement;
-    newFilterInput: IronInputElement;
-    newProject: GrAutocomplete;
-  };
-}
-
 @customElement('gr-watched-projects-editor')
-export class GrWatchedProjectsEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrWatchedProjectsEditor extends LitElement {
+  // Private but used in tests.
+  @query('#newFilter')
+  newFilter?: HTMLInputElement;
 
-  @property({type: Boolean, notify: true})
+  // Private but used in tests.
+  @query('#newProject')
+  newProject?: GrAutocomplete;
+
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
   @property({type: Array})
-  _projects?: ProjectWatchInfo[];
+  projects?: ProjectWatchInfo[];
 
   @property({type: Array})
-  _projectsToRemove: ProjectWatchInfo[] = [];
+  projectsToRemove: ProjectWatchInfo[] = [];
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  private readonly query: AutocompleteQuery = input =>
+    this.getProjectSuggestions(input);
 
   private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = input => this._getProjectSuggestions(input);
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        #watchedProjects .notifType {
+          text-align: center;
+          padding: 0 var(--spacing-s);
+        }
+        .notifControl {
+          cursor: pointer;
+          text-align: center;
+        }
+        .notifControl:hover {
+          outline: 1px solid var(--border-color);
+        }
+        .projectFilter {
+          color: var(--deemphasized-text-color);
+          font-style: italic;
+          margin-left: var(--spacing-l);
+        }
+        .newFilterInput {
+          width: 100%;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const types = NOTIFICATION_TYPES;
+    return html` <div class="gr-form-styles">
+      <table id="watchedProjects">
+        <thead>
+          <tr>
+            <th>Repo</th>
+            ${types.map(type => html`<th class="notifType">${type.name}</th>`)}
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${(this.projects ?? []).map(project => this.renderProject(project))}
+        </tbody>
+        <tfoot>
+          <tr>
+            <th>
+              <gr-autocomplete
+                id="newProject"
+                query=${this.query}
+                threshold="1"
+                allow-non-suggested-values
+                tab-complete
+                placeholder="Repo"
+              ></gr-autocomplete>
+            </th>
+            <th colspan=${types.length}>
+              <iron-input id="newFilterInput" class="newFilterInput">
+                <input
+                  id="newFilter"
+                  class="newFilterInput"
+                  placeholder="branch:name, or other search expression"
+                />
+              </iron-input>
+            </th>
+            <th>
+              <gr-button link="" @click=${this.handleAddProject}>Add</gr-button>
+            </th>
+          </tr>
+        </tfoot>
+      </table>
+    </div>`;
+  }
+
+  private renderProject(project: ProjectWatchInfo) {
+    const types = NOTIFICATION_TYPES;
+    return html` <tr>
+      <td>
+        ${project.project}
+        ${when(
+          project.filter,
+          () => html`<div class="projectFilter">${project.filter}</div>`
+        )}
+      </td>
+      ${types.map(type => this.renderNotifyControl(project, type.key))}
+      <td>
+        <gr-button
+          link=""
+          @click=${(_e: Event) => this.handleRemoveProject(project)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  private renderNotifyControl(project: ProjectWatchInfo, key: NotificationKey) {
+    return html` <td class="notifControl" @click=${this.handleNotifCellClick}>
+      <input
+        type="checkbox"
+        data-key=${key}
+        @change=${(e: Event) => this.handleCheckboxChange(project, key, e)}
+        ?checked=${!!project[key]}
+      />
+    </td>`;
   }
 
   loadData() {
     return this.restApiService.getWatchedProjects().then(projs => {
-      this._projects = projs;
+      this.projects = projs;
     });
   }
 
   save() {
     let deletePromise: Promise<Response | undefined>;
-    if (this._projectsToRemove.length) {
+    if (this.projectsToRemove.length) {
       deletePromise = this.restApiService.deleteWatchedProjects(
-        this._projectsToRemove
+        this.projectsToRemove
       );
     } else {
       deletePromise = Promise.resolve(undefined);
@@ -92,32 +189,21 @@
 
     return deletePromise
       .then(() => {
-        if (this._projects) {
-          return this.restApiService.saveWatchedProjects(this._projects);
+        if (this.projects) {
+          return this.restApiService.saveWatchedProjects(this.projects);
         } else {
           return Promise.resolve(undefined);
         }
       })
       .then(projects => {
-        this._projects = projects;
-        this._projectsToRemove = [];
-        this.hasUnsavedChanges = false;
+        this.projects = projects;
+        this.projectsToRemove = [];
+        this.setHasUnsavedChanges(false);
       });
   }
 
-  _getTypes() {
-    return NOTIFICATION_TYPES;
-  }
-
-  _getTypeCount() {
-    return this._getTypes().length;
-  }
-
-  _computeCheckboxChecked(project: ProjectWatchInfo, key: string) {
-    return hasOwnProperty(project, key);
-  }
-
-  _getProjectSuggestions(input: string) {
+  // private but used in tests.
+  getProjectSuggestions(input: string) {
     return this.restApiService.getSuggestedProjects(input).then(response => {
       const projects: AutocompleteSuggestion[] = [];
       for (const [name, project] of Object.entries(response ?? {})) {
@@ -127,18 +213,18 @@
     });
   }
 
-  _handleRemoveProject(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
-    const dataIndex = el.getAttribute('data-index');
-    if (dataIndex === null || !this._projects) return;
-    const index = Number(dataIndex);
-    const project = this._projects[index];
-    this.splice('_projects', index, 1);
-    this.push('_projectsToRemove', project);
-    this.hasUnsavedChanges = true;
+  private handleRemoveProject(project: ProjectWatchInfo) {
+    if (!this.projects) return;
+    const index = this.projects.indexOf(project);
+    if (index < 0) return;
+    this.projects.splice(index, 1);
+    this.projectsToRemove.push(project);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _canAddProject(
+  // private but used in tests.
+  canAddProject(
     project: string | null,
     text: string | null,
     filter: string | null
@@ -152,12 +238,12 @@
       return true;
     }
 
-    if (!this._projects) return true;
+    if (!this.projects) return true;
     // Check if the project with filter is already in the list.
-    for (let i = 0; i < this._projects.length; i++) {
+    for (let i = 0; i < this.projects.length; i++) {
       if (
-        this._projects[i].project === project &&
-        this.areFiltersEqual(this._projects[i].filter, filter)
+        this.projects[i].project === project &&
+        this.areFiltersEqual(this.projects[i].filter, filter)
       ) {
         return false;
       }
@@ -166,14 +252,15 @@
     return true;
   }
 
-  _getNewProjectIndex(name: string, filter: string | null) {
-    if (!this._projects) return;
+  // private but used in tests.
+  getNewProjectIndex(name: string, filter: string | null) {
+    if (!this.projects) return;
     let i;
-    for (i = 0; i < this._projects.length; i++) {
-      const projectFilter = this._projects[i].filter;
+    for (i = 0; i < this.projects.length; i++) {
+      const projectFilter = this.projects[i].filter;
       if (
-        this._projects[i].project > name ||
-        (this._projects[i].project === name &&
+        this.projects[i].project > name ||
+        (this.projects[i].project === name &&
           this.isFilterDefined(projectFilter) &&
           this.isFilterDefined(filter) &&
           projectFilter! > filter!)
@@ -184,43 +271,47 @@
     return i;
   }
 
-  _handleAddProject() {
-    const newProject = this.$.newProject.value;
-    const newProjectName = this.$.newProject.text;
-    const filter = this.$.newFilter.value || null;
+  // Private but used in tests.
+  handleAddProject() {
+    assertIsDefined(this.newProject, 'newProject');
+    assertIsDefined(this.newFilter, 'newFilter');
+    const newProject = this.newProject.value;
+    const newProjectName = this.newProject.text as RepoName;
+    const filter = this.newFilter.value;
 
-    if (!this._canAddProject(newProject, newProjectName, filter)) {
+    if (!this.canAddProject(newProject, newProjectName, filter)) {
       return;
     }
 
-    const insertIndex = this._getNewProjectIndex(newProjectName, filter);
+    const insertIndex = this.getNewProjectIndex(newProjectName, filter);
 
     if (insertIndex !== undefined) {
-      this.splice('_projects', insertIndex, 0, {
+      this.projects?.splice(insertIndex, 0, {
         project: newProjectName,
         filter,
         _is_local: true,
       });
+      this.requestUpdate();
     }
 
-    this.$.newProject.clear();
-    this.$.newFilter.value = '';
-    this.hasUnsavedChanges = true;
+    this.newProject.clear();
+    this.newFilter.value = '';
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleCheckboxChange(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
-    if (el === null) return;
-    const dataIndex = el.getAttribute('data-index');
-    const key = el.getAttribute('data-key');
-    if (dataIndex === null || key === null) return;
-    const index = Number(dataIndex);
+  private handleCheckboxChange(
+    project: ProjectWatchInfo,
+    key: NotificationKey,
+    e: Event
+  ) {
+    const el = e.target as HTMLInputElement;
     const checked = el.checked;
-    this.set(['_projects', index, key], !!checked);
-    this.hasUnsavedChanges = true;
+    project[key] = !!checked;
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleNotifCellClick(e: Event) {
+  private handleNotifCellClick(e: Event) {
     if (e.target === null) return;
     const checkbox = (e.target as HTMLElement).querySelector('input');
     if (checkbox) {
@@ -228,6 +319,11 @@
     }
   }
 
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
+  }
+
   isFilterDefined(filter: string | null | undefined) {
     return filter !== null && filter !== undefined;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
deleted file mode 100644
index fb65a03..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #watchedProjects .notifType {
-      text-align: center;
-      padding: 0 var(--spacing-s);
-    }
-    .notifControl {
-      cursor: pointer;
-      text-align: center;
-    }
-    .notifControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-    .projectFilter {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-      margin-left: var(--spacing-l);
-    }
-    .newFilterInput {
-      width: 100%;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="watchedProjects">
-      <thead>
-        <tr>
-          <th>Repo</th>
-          <template is="dom-repeat" items="[[_getTypes()]]">
-            <th class="notifType">[[item.name]]</th>
-          </template>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template
-          is="dom-repeat"
-          items="[[_projects]]"
-          as="project"
-          index-as="projectIndex"
-        >
-          <tr>
-            <td>
-              [[project.project]]
-              <template is="dom-if" if="[[project.filter]]">
-                <div class="projectFilter">[[project.filter]]</div>
-              </template>
-            </td>
-            <template is="dom-repeat" items="[[_getTypes()]]" as="type">
-              <td class="notifControl" on-click="_handleNotifCellClick">
-                <input
-                  type="checkbox"
-                  data-index$="[[projectIndex]]"
-                  data-key$="[[type.key]]"
-                  on-change="_handleCheckboxChange"
-                  checked$="[[_computeCheckboxChecked(project, type.key)]]"
-                />
-              </td>
-            </template>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[projectIndex]]"
-                on-click="_handleRemoveProject"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <gr-autocomplete
-              id="newProject"
-              query="[[_query]]"
-              threshold="1"
-              allow-non-suggested-values=""
-              tab-complete=""
-              placeholder="Repo"
-            ></gr-autocomplete>
-          </th>
-          <th colspan$="[[_getTypeCount()]]">
-            <iron-input
-              id="newFilterInput"
-              class="newFilterInput"
-              placeholder="branch:name, or other search expression"
-            >
-              <input
-                id="newFilter"
-                class="newFilterInput"
-                placeholder="branch:name, or other search expression"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <gr-button link="" on-click="_handleAddProject">Add</gr-button>
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index c0580f6..bc21460 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -22,6 +22,8 @@
 import {ProjectWatchInfo} from '../../../types/common';
 import {queryAll, queryAndAssert} from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {IronInputElement} from '@polymer/iron-input';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
@@ -70,7 +72,7 @@
     element = basicFixture.instantiate();
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -101,69 +103,70 @@
     assert.equal(checkedKeys[2], 'notify_all_comments');
   });
 
-  test('_getProjectSuggestions empty', async () => {
-    const projects = await element._getProjectSuggestions('nonexistent');
+  test('getProjectSuggestions empty', async () => {
+    const projects = await element.getProjectSuggestions('nonexistent');
     assert.equal(projects.length, 0);
   });
 
-  test('_getProjectSuggestions non-empty', async () => {
-    const projects = await element._getProjectSuggestions('the project');
+  test('getProjectSuggestions non-empty', async () => {
+    const projects = await element.getProjectSuggestions('the project');
     assert.equal(projects.length, 1);
     assert.equal(projects[0].name, 'the project');
   });
 
-  test('_getProjectSuggestions non-empty with two letter project', async () => {
-    const projects = await element._getProjectSuggestions('th');
+  test('getProjectSuggestions non-empty with two letter project', async () => {
+    const projects = await element.getProjectSuggestions('th');
     assert.equal(projects.length, 1);
     assert.equal(projects[0].name, 'the project');
   });
 
   test('_canAddProject', () => {
-    assert.isFalse(element._canAddProject(null, null, null));
+    assert.isFalse(element.canAddProject(null, null, null));
 
     // Can add a project that is not in the list.
-    assert.isTrue(element._canAddProject('project d', null, null));
-    assert.isTrue(element._canAddProject('project d', null, 'filter 3'));
+    assert.isTrue(element.canAddProject('project d', null, null));
+    assert.isTrue(element.canAddProject('project d', null, 'filter 3'));
 
     // Cannot add a project that is in the list with no filter.
-    assert.isFalse(element._canAddProject('project a', null, null));
+    assert.isFalse(element.canAddProject('project a', null, null));
 
     // Can add a project that is in the list if the filter differs.
-    assert.isTrue(element._canAddProject('project a', null, 'filter 4'));
+    assert.isTrue(element.canAddProject('project a', null, 'filter 4'));
 
     // Cannot add a project that is in the list with the same filter.
-    assert.isFalse(element._canAddProject('project b', null, 'filter 1'));
-    assert.isFalse(element._canAddProject('project b', null, 'filter 2'));
+    assert.isFalse(element.canAddProject('project b', null, 'filter 1'));
+    assert.isFalse(element.canAddProject('project b', null, 'filter 2'));
 
     // Can add a project that is in the list using a new filter.
-    assert.isTrue(element._canAddProject('project b', null, 'filter 3'));
+    assert.isTrue(element.canAddProject('project b', null, 'filter 3'));
 
     // Can add a project that is not added by the auto complete
-    assert.isTrue(element._canAddProject(null, 'test', null));
+    assert.isTrue(element.canAddProject(null, 'test', null));
   });
 
-  test('_getNewProjectIndex', () => {
+  test('getNewProjectIndex', () => {
     // Projects are sorted in ASCII order.
-    assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
-    assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+    assert.equal(element.getNewProjectIndex('project A', 'filter'), 0);
+    assert.equal(element.getNewProjectIndex('project a', 'filter'), 1);
 
     // Projects are sorted by filter when the names are equal
-    assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 0'), 1);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 1.5'), 2);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 3'), 3);
 
     // Projects with filters follow those without
-    assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+    assert.equal(element.getNewProjectIndex('project c', 'filter'), 4);
   });
 
-  test('_handleAddProject', () => {
-    element.$.newProject.value = 'project d';
-    element.$.newProject.setText('project d');
-    element.$.newFilterInput.bindValue = '';
+  test('handleAddProject', () => {
+    assertIsDefined(element.newProject, 'newProject');
+    element.newProject.value = 'project d';
+    element.newProject.setText('project d');
+    queryAndAssert<IronInputElement>(element, '#newFilterInput').bindValue = '';
 
-    element._handleAddProject();
+    element.handleAddProject();
 
-    const projects = element._projects!;
+    const projects = element.projects!;
     assert.equal(projects.length, 5);
     assert.equal(projects[4].project, 'project d');
     assert.isNotOk(projects[4].filter);
@@ -171,18 +174,21 @@
   });
 
   test('_handleAddProject with invalid inputs', () => {
-    element.$.newProject.value = 'project b';
-    element.$.newProject.setText('project b');
-    element.$.newFilterInput.bindValue = 'filter 1';
-    element.$.newFilter.value = 'filter 1';
+    assertIsDefined(element.newProject, 'newProject');
+    element.newProject.value = 'project b';
+    element.newProject.setText('project b');
+    queryAndAssert<IronInputElement>(element, '#newFilterInput').bindValue =
+      'filter 1';
+    assertIsDefined(element.newFilter, 'newFilter');
+    element.newFilter.value = 'filter 1';
 
-    element._handleAddProject();
+    element.handleAddProject();
 
-    assert.equal(element._projects!.length, 4);
+    assert.equal(element.projects!.length, 4);
   });
 
-  test('_handleRemoveProject', () => {
-    assert.deepEqual(element._projectsToRemove, []);
+  test('_handleRemoveProject', async () => {
+    assert.deepEqual(element.projectsToRemove, []);
 
     const button = queryAndAssert(
       element,
@@ -190,13 +196,13 @@
     );
     MockInteractions.tap(button);
 
-    flush();
+    await element.updateComplete;
 
     const rows = queryAndAssert(element, 'table tbody').querySelectorAll('tr');
 
     assert.equal(rows.length, 3);
 
-    assert.equal(element._projectsToRemove.length, 1);
-    assert.equal(element._projectsToRemove[0].project, 'project b');
+    assert.equal(element.projectsToRemove.length, 1);
+    assert.equal(element.projectsToRemove[0].project, 'project b');
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index cf36fc1..7f20a1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-account-link/gr-account-link';
+import '../gr-account-label/gr-account-label';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import {
@@ -27,7 +27,6 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {ClassInfo, classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
 
 @customElement('gr-account-chip')
@@ -94,8 +93,6 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -142,7 +139,7 @@
           height: 1.2rem;
           width: 1.2rem;
         }
-        .container gr-account-link::part(gr-account-link-text) {
+        .container gr-account-label::part(gr-account-label-text) {
           color: var(--deemphasized-text-color);
         }
         .container.disliked {
@@ -190,31 +187,34 @@
     `;
     return html`${customStyle}
       <div
-        class="${classMap({
+        class=${classMap({
           ...this.computeVoteClasses(),
           container: true,
           transparentBackground: this.transparentBackground,
           closeShown: this.removable,
-        })}"
+        })}
       >
-        <gr-account-link
-          .account="${this.account}"
-          .change="${this.change}"
-          ?forceAttention=${this.forceAttention}
-          ?highlightAttention=${this.highlightAttention}
-          .voteableText=${this.voteableText}
-        >
-        </gr-account-link>
+        <div>
+          <gr-account-label
+            .account=${this.account}
+            .change=${this.change}
+            ?forceAttention=${this.forceAttention}
+            ?highlightAttention=${this.highlightAttention}
+            .voteableText=${this.voteableText}
+            clickable
+          >
+          </gr-account-label>
+        </div>
         <slot name="vote-chip"></slot>
         <gr-button
           id="remove"
           link=""
           ?hidden=${!this.removable}
           aria-label="Remove"
-          class="${classMap({
+          class=${classMap({
             remove: true,
             transparentBackground: this.transparentBackground,
-          })}"
+          })}
           @click=${this._handleRemoveTap}
         >
           <iron-icon icon="gr-icons:close"></iron-icon>
@@ -249,12 +249,7 @@
   }
 
   private computeVoteClasses(): ClassInfo {
-    if (
-      !this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) ||
-      !this.label ||
-      !this.account ||
-      !hasVoted(this.label, this.account)
-    ) {
+    if (!this.label || !this.account || !hasVoted(this.label, this.account)) {
       return {};
     }
     const status = getLabelStatus(this.label, this.vote?.value);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
index 1ad8b41..0379c8a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
@@ -39,7 +39,9 @@
   test('renders', () => {
     expect(element).shadowDom.to.equal(/* HTML */ `
       <div class="container">
-        <gr-account-link></gr-account-link>
+        <div>
+          <gr-account-label clickable="" deselected=""></gr-account-label>
+        </div>
         <slot name="vote-chip"></slot>
         <gr-button
           aria-disabled="false"
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index acb8348..c642db1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -14,30 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
+
 import '../gr-autocomplete/gr-autocomplete';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-entry_html';
-import {customElement, property} from '@polymer/decorators';
 import {
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {SuggestedReviewerInfo} from '../../../types/common';
 
-export interface GrAccountEntry {
-  $: {
-    input: GrAutocomplete;
-  };
-}
 /**
  * gr-account-entry is an element for entering account
  * and/or group with autocomplete support.
  */
 @customElement('gr-account-entry')
-export class GrAccountEntry extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAccountEntry extends LitElement {
+  @query('#input') private input?: GrAutocomplete;
 
   /**
    * Fired when an account is entered.
@@ -62,33 +57,71 @@
   @property({type: String})
   placeholder = '';
 
-  @property({type: Object, notify: true})
-  querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
+  @property({type: Object})
+  querySuggestions: AutocompleteQuery<SuggestedReviewerInfo> = () =>
+    Promise.resolve([]);
 
-  @property({type: String, observer: '_inputTextChanged'})
-  _inputText = '';
+  @state() private inputText = '';
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        gr-autocomplete {
+          display: inline-block;
+          flex: 1;
+          overflow: hidden;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-autocomplete
+        id="input"
+        .borderless=${this.borderless}
+        .placeholder=${this.placeholder}
+        .query=${this.querySuggestions}
+        allow-non-suggested-values=${this.allowAnyInput}
+        @commit=${this.handleInputCommit}
+        clear-on-commit
+        warn-uncommitted
+        .text=${this.inputText}
+        .verticalOffset=${24}
+        @text-changed=${this.handleTextChanged}
+      >
+      </gr-autocomplete>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('inputText')) {
+      this.inputTextChanged();
+    }
+  }
 
   get focusStart() {
-    return this.$.input.focusStart;
+    return this.input!.focusStart;
   }
 
   override focus() {
-    this.$.input.focus();
+    this.input!.focus();
   }
 
   clear() {
-    this.$.input.clear();
+    this.input!.clear();
   }
 
   setText(text: string) {
-    this.$.input.setText(text);
+    this.input!.setText(text);
   }
 
   getText() {
-    return this.$.input.text;
+    return this.input!.text;
   }
 
-  _handleInputCommit(e: CustomEvent) {
+  private handleInputCommit(e: CustomEvent) {
     this.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: e.detail.value},
@@ -96,16 +129,20 @@
         bubbles: true,
       })
     );
-    this.$.input.focus();
+    this.input!.focus();
   }
 
-  _inputTextChanged(text: string) {
-    if (text.length && this.allowAnyInput) {
+  private inputTextChanged() {
+    if (this.inputText.length && this.allowAnyInput) {
       this.dispatchEvent(
         new CustomEvent('account-text-changed', {bubbles: true, composed: true})
       );
     }
   }
+
+  private handleTextChanged(e: BindValueChangeEvent) {
+    this.inputText = e.detail.value;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
deleted file mode 100644
index d84ef62..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-autocomplete {
-      display: inline-block;
-      flex: 1;
-      overflow: hidden;
-    }
-  </style>
-  <gr-autocomplete
-    id="input"
-    borderless="[[borderless]]"
-    placeholder="[[placeholder]]"
-    query="[[querySuggestions]]"
-    allow-non-suggested-values="[[allowAnyInput]]"
-    on-commit="_handleInputCommit"
-    clear-on-commit=""
-    warn-uncommitted=""
-    text="{{_inputText}}"
-    vertical-offset="24"
-  >
-  </gr-autocomplete>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
index 4bb2232..f08da9e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
@@ -18,48 +18,59 @@
 import '../../../test/common-test-setup-karma';
 import './gr-account-entry';
 import {GrAccountEntry} from './gr-account-entry';
-
-const basicFixture = fixtureFromElement('gr-account-entry');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+import {PaperInputElementExt} from '../../../types/types';
 
 suite('gr-account-entry tests', () => {
   let element: GrAccountEntry;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrAccountEntry>(html`
+      <gr-account-entry></gr-account-entry>
+    `);
+    await element.updateComplete;
   });
 
-  test('account-text-changed fired when input text changed and allowAnyInput', () => {
+  test('account-text-changed fired when input text changed and allowAnyInput', async () => {
     // Spy on query, as that is called when _updateSuggestions proceeds.
     const changeStub = sinon.stub();
     element.allowAnyInput = true;
     element.querySuggestions = () => Promise.resolve([]);
     element.addEventListener('account-text-changed', changeStub);
-    element.$.input.text = 'a';
-    assert.isTrue(changeStub.calledOnce);
-    element.$.input.text = 'ab';
-    assert.isTrue(changeStub.calledTwice);
+    queryAndAssert<GrAutocomplete>(element, '#input').text = 'a';
+    await element.updateComplete;
+    await waitUntil(() => changeStub.calledOnce);
+    queryAndAssert<GrAutocomplete>(element, '#input').text = 'ab';
+    await element.updateComplete;
+    await waitUntil(() => changeStub.calledTwice);
   });
 
-  test(
-    'account-text-changed not fired when input text changed without ' +
-      'allowAnyInput',
-    () => {
-      // Spy on query, as that is called when _updateSuggestions proceeds.
-      const changeStub = sinon.stub();
-      element.querySuggestions = () => Promise.resolve([]);
-      element.addEventListener('account-text-changed', changeStub);
-      element.$.input.text = 'a';
-      assert.isFalse(changeStub.called);
-    }
-  );
-
-  test('setText', () => {
+  test('account-text-changed not fired when input text changed without allowAnyInput', async () => {
     // Spy on query, as that is called when _updateSuggestions proceeds.
-    const suggestSpy = sinon.spy(element.$.input, 'query');
-    element.setText('test text');
-    flush();
+    const changeStub = sinon.stub();
+    element.querySuggestions = () => Promise.resolve([]);
+    element.addEventListener('account-text-changed', changeStub);
+    queryAndAssert<GrAutocomplete>(element, '#input').text = 'a';
+    await element.updateComplete;
+    assert.isFalse(changeStub.called);
+  });
 
-    assert.equal(element.$.input.$.input.value, 'test text');
-    assert.isFalse(suggestSpy.called);
+  test('setText', async () => {
+    // Stub on query, as that is called when _updateSuggestions proceeds.
+    const suggestStub = sinon.stub(
+      queryAndAssert<GrAutocomplete>(element, '#input'),
+      'query'
+    );
+    element.setText('test text');
+    await element.updateComplete;
+
+    const input = queryAndAssert<GrAutocomplete>(element, '#input');
+    assert.equal(
+      queryAndAssert<PaperInputElementExt>(input, '#input').value,
+      'test text'
+    );
+    assert.isFalse(suggestStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index ac5b4d7..a505632 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -27,11 +27,12 @@
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {ShowAlertEventDetail} from '../../../types/events';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, TemplateResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {classMap} from 'lit/directives/class-map';
-import {modifierPressed} from '../../../utils/dom-util';
 import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
+import {ifDefined} from 'lit/directives/if-defined';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends LitElement {
@@ -98,6 +99,9 @@
   deselected = false;
 
   @property({type: Boolean, reflect: true})
+  clickable = false;
+
+  @property({type: Boolean, reflect: true})
   attentionIconShown = false;
 
   @property({type: Boolean, reflect: true})
@@ -174,7 +178,6 @@
         }
         .name {
           display: inline-block;
-          text-decoration: inherit;
           vertical-align: top;
           overflow: hidden;
           text-overflow: ellipsis;
@@ -183,6 +186,16 @@
         .hasAttention .name {
           font-weight: var(--font-weight-bold);
         }
+        a.ownerLink {
+          text-decoration: none;
+          color: var(--primary-text-color);
+          display: flex;
+          align-items: center;
+          gap: 3px;
+        }
+        :host([clickable]) a.ownerLink:hover .name {
+          text-decoration: underline;
+        }
       `,
     ];
   }
@@ -192,7 +205,7 @@
     if (!account) return;
     this.attentionIconShown =
       forceAttention ||
-      this._hasUnforcedAttention(highlightAttention, account, change);
+      this.hasUnforcedAttention(highlightAttention, account, change);
     this.deselected = !this.selected;
     const hasAvatars = !!_config?.plugin?.has_avatars;
     this.avatarShown = !this.hideAvatar && hasAvatars;
@@ -210,28 +223,28 @@
           : ''}
         ${this.attentionIconShown
           ? html` <gr-tooltip-content
-              ?has-tooltip=${this._computeAttentionButtonEnabled(
+              ?has-tooltip=${this.computeAttentionButtonEnabled(
                 highlightAttention,
                 account,
                 change,
                 false,
                 this._selfAccount
               )}
-              title="${this._computeAttentionIconTitle(
+              title=${this.computeAttentionIconTitle(
                 highlightAttention,
                 account,
                 change,
                 forceAttention,
                 this.selected,
                 this._selfAccount
-              )}"
+              )}
             >
               <gr-button
                 id="attentionButton"
                 link=""
                 aria-label="Remove user from attention set"
-                @click=${this._handleRemoveAttentionClick}
-                ?disabled=${!this._computeAttentionButtonEnabled(
+                @click=${this.handleRemoveAttentionClick}
+                ?disabled=${!this.computeAttentionButtonEnabled(
                   highlightAttention,
                   account,
                   change,
@@ -245,22 +258,28 @@
               </gr-button>
             </gr-tooltip-content>`
           : ''}
-        <span
-          tabindex="0"
-          @keydown="${(e: KeyboardEvent) => this.handleKeyDown(e)}"
-          class="${classMap({
-            hovercardTargetWrapper: true,
-            hasAttention: this.attentionIconShown,
-          })}"
-        >
-          ${this.avatarShown
-            ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
-            : ''}
-          <span id="hovercardTarget" class="name" part="gr-account-label-text">
-            ${this._computeName(account, this.firstName, this._config)}
+        ${this.maybeRenderLink(html`
+          <span
+            class=${classMap({
+              hovercardTargetWrapper: true,
+              hasAttention: this.attentionIconShown,
+            })}
+          >
+            ${this.avatarShown
+              ? html`<gr-avatar .account=${account} imageSize="32"></gr-avatar>`
+              : ''}
+            <span
+              tabindex=${this.hideHovercard ? '-1' : '0'}
+              role=${ifDefined(this.hideHovercard ? undefined : 'button')}
+              id="hovercardTarget"
+              class="name"
+              part="gr-account-label-text"
+            >
+              ${this.computeName(account, this.firstName, this._config)}
+            </span>
+            ${this.renderAccountStatusPlugins()}
           </span>
-          ${this.renderAccountStatusPlugins()}
-        </span>
+        `)}
       </div>
     `;
   }
@@ -279,6 +298,18 @@
     });
   }
 
+  private maybeRenderLink(span: TemplateResult) {
+    if (!this.clickable || !this.account) return span;
+    const url = GerritNav.getUrlForOwner(
+      this.account.email ||
+        this.account.username ||
+        this.account.name ||
+        `${this.account._account_id}`
+    );
+    if (!url) return span;
+    return html`<a class="ownerLink" href=${url} tabindex="-1">${span}</a>`;
+  }
+
   private renderAccountStatusPlugins() {
     if (!this.account?._account_id || this.noStatusIcons) {
       return;
@@ -290,23 +321,14 @@
       >
         <gr-endpoint-param
           name="accountId"
-          .value="${this.account._account_id}"
+          .value=${this.account._account_id}
         ></gr-endpoint-param>
         <span class="rightSidePadding"></span>
       </gr-endpoint-decorator>
     `;
   }
 
-  handleKeyDown(e: KeyboardEvent) {
-    if (modifierPressed(e)) return;
-    // Only react to `return` and `space`.
-    if (e.keyCode !== 13 && e.keyCode !== 32) return;
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new Event('click'));
-  }
-
-  _isAttentionSetEnabled(
+  private isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
     change?: ChangeInfo
@@ -314,13 +336,13 @@
     return highlight && !!change && !!account && !isServiceUser(account);
   }
 
-  _hasUnforcedAttention(
+  private hasUnforcedAttention(
     highlight: boolean,
     account: AccountInfo,
     change?: ChangeInfo
   ): boolean {
     return !!(
-      this._isAttentionSetEnabled(highlight, account, change) &&
+      this.isAttentionSetEnabled(highlight, account, change) &&
       change &&
       change.attention_set &&
       !!account._account_id &&
@@ -328,15 +350,12 @@
     );
   }
 
-  _computeName(
-    account?: AccountInfo,
-    firstName?: boolean,
-    config?: ServerInfo
-  ) {
+  // Private but used in tests.
+  computeName(account?: AccountInfo, firstName?: boolean, config?: ServerInfo) {
     return getDisplayName(config, account, firstName);
   }
 
-  _handleRemoveAttentionClick(e: MouseEvent) {
+  private handleRemoveAttentionClick(e: MouseEvent) {
     if (!this.account || !this.change) return;
     if (this.selected) return;
     e.preventDefault();
@@ -364,7 +383,7 @@
 
     this.reporting.reportInteraction(
       'attention-icon-remove',
-      this._reportingDetails()
+      this.reportingDetails()
     );
     this.restApiService
       .removeFromAttentionSet(
@@ -377,7 +396,7 @@
       });
   }
 
-  _reportingDetails() {
+  private reportingDetails() {
     if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
@@ -399,7 +418,7 @@
     };
   }
 
-  _computeAttentionButtonEnabled(
+  private computeAttentionButtonEnabled(
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo | undefined,
@@ -408,12 +427,12 @@
   ) {
     if (selected) return true;
     return (
-      !!this._hasUnforcedAttention(highlight, account, change) &&
+      !!this.hasUnforcedAttention(highlight, account, change) &&
       (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
     );
   }
 
-  _computeAttentionIconTitle(
+  private computeAttentionIconTitle(
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo | undefined,
@@ -421,7 +440,7 @@
     selected: boolean,
     selfAccount?: AccountInfo
   ) {
-    const enabled = this._computeAttentionButtonEnabled(
+    const enabled = this.computeAttentionButtonEnabled(
       highlight,
       account,
       change,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index d9c0665..d318a7d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -24,9 +24,10 @@
   stubRestApi,
 } from '../../../test/test-utils';
 import {GrAccountLabel} from './gr-account-label';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
-  createAccountDetailWithId,
+  createAccountDetailWithIdNameAndEmail,
   createChange,
   createPluginConfig,
   createServerInfo,
@@ -38,11 +39,12 @@
 suite('gr-account-label tests', () => {
   let element: GrAccountLabel;
   const kermit: AccountDetailInfo = {
-    ...createAccountDetailWithId(31),
+    ...createAccountDetailWithIdNameAndEmail(31),
     name: 'kermit',
   };
 
   setup(async () => {
+    sinon.stub(GerritNav, 'getUrlForOwner').callsFake(() => 'test');
     stubRestApi('getAccount').resolves(kermit);
     stubRestApi('getLoggedIn').resolves(false);
     stubRestApi('getConfig').resolves({
@@ -65,9 +67,15 @@
     expect(element).shadowDom.to.equal(/* HTML */ `
       <div class="container">
         <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
-        <span class="hovercardTargetWrapper" tabindex="0">
+        <span class="hovercardTargetWrapper">
           <gr-avatar hidden="" imagesize="32"> </gr-avatar>
-          <span class="name" id="hovercardTarget" part="gr-account-label-text">
+          <span
+            class="name"
+            id="hovercardTarget"
+            part="gr-account-label-text"
+            role="button"
+            tabindex="0"
+          >
             kermit
           </span>
           <gr-endpoint-decorator
@@ -82,15 +90,47 @@
     `);
   });
 
+  test('renders clickable', async () => {
+    element.account = kermit;
+    element.clickable = true;
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="container">
+        <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
+        <a class="ownerLink" href="test" tabindex="-1">
+          <span class="hovercardTargetWrapper">
+            <gr-avatar hidden="" imagesize="32"> </gr-avatar>
+            <span
+              class="name"
+              id="hovercardTarget"
+              part="gr-account-label-text"
+              role="button"
+              tabindex="0"
+            >
+              kermit
+            </span>
+            <gr-endpoint-decorator
+              class="accountStatusDecorator"
+              name="account-status-icon"
+            >
+              <gr-endpoint-param name="accountId"></gr-endpoint-param>
+              <span class="rightSidePadding"></span>
+            </gr-endpoint-decorator>
+          </span>
+        </a>
+      </div>
+    `);
+  });
+
   suite('_computeName', () => {
     test('not showing anonymous', () => {
       const account = {name: 'Wyatt'};
-      assert.deepEqual(element._computeName(account, false), 'Wyatt');
+      assert.deepEqual(element.computeName(account, false), 'Wyatt');
     });
 
     test('showing anonymous but no config', () => {
       const account = {};
-      assert.deepEqual(element._computeName(account, false), 'Anonymous');
+      assert.deepEqual(element.computeName(account, false), 'Anonymous');
     });
 
     test('test for Anonymous Coward user and replace with Anonymous', () => {
@@ -102,7 +142,7 @@
       };
       const account = {};
       assert.deepEqual(
-        element._computeName(account, false, config),
+        element.computeName(account, false, config),
         'Anonymous'
       );
     });
@@ -115,10 +155,7 @@
         },
       };
       const account = {};
-      assert.deepEqual(
-        element._computeName(account, false, config),
-        'TestAnon'
-      );
+      assert.deepEqual(element.computeName(account, false, config), 'TestAnon');
     });
   });
 
@@ -131,14 +168,14 @@
       };
       element._selfAccount = kermit;
       element.account = {
-        ...createAccountDetailWithId(42),
+        ...createAccountDetailWithIdNameAndEmail(42),
         name: 'ernie',
       };
       element.change = {
         ...createChange(),
         attention_set: {
           42: {
-            account: createAccountDetailWithId(42),
+            account: createAccountDetailWithIdNameAndEmail(42),
           },
         },
         owner: kermit,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
deleted file mode 100644
index 34e5043..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../gr-account-label/gr-account-label';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AccountInfo, ChangeInfo} from '../../../types/common';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {ParsedChangeInfo} from '../../../types/types';
-
-@customElement('gr-account-link')
-export class GrAccountLink extends LitElement {
-  @property({type: String})
-  voteableText?: string;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  /**
-   * Optional ChangeInfo object, typically comes from the change page or
-   * from a row in a list of search results. This is needed for some change
-   * related features like adding the user as a reviewer.
-   */
-  @property({type: Object})
-  change?: ChangeInfo | ParsedChangeInfo;
-
-  /**
-   * Should this user be considered to be in the attention set, regardless
-   * of the current state of the change object?
-   */
-  @property({type: Boolean})
-  forceAttention = false;
-
-  /**
-   * Should attention set related features be shown in the component? Note
-   * that the information whether the user is in the attention set or not is
-   * part of the ChangeInfo object in the change property.
-   */
-  @property({type: Boolean})
-  highlightAttention = false;
-
-  @property({type: Boolean})
-  hideAvatar = false;
-
-  /**
-   * Only show the first name in the account label.
-   */
-  @property({type: Boolean})
-  firstName = false;
-
-  static override get styles() {
-    return [
-      css`
-        :host {
-          display: inline-block;
-          vertical-align: top;
-        }
-        a {
-          color: var(--primary-text-color);
-          text-decoration: none;
-        }
-        gr-account-label::part(gr-account-label-text):hover {
-          text-decoration: underline !important;
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (!this.account) return;
-    return html`<span>
-      <a href="${this._computeOwnerLink(this.account)}">
-        <gr-account-label
-          .account="${this.account}"
-          .change="${this.change}"
-          ?forceAttention=${this.forceAttention}
-          ?highlightAttention=${this.highlightAttention}
-          ?hideAvatar=${this.hideAvatar}
-          ?firstName=${this.firstName}
-          .voteableText=${this.voteableText}
-          exportparts="gr-account-label-text: gr-account-link-text"
-        >
-        </gr-account-label>
-      </a>
-    </span>`;
-  }
-
-  _computeOwnerLink(account?: AccountInfo) {
-    if (!account) {
-      return;
-    }
-    return GerritNav.getUrlForOwner(
-      account.email ||
-        account.username ||
-        account.name ||
-        `${account._account_id}`
-    );
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-account-link': GrAccountLink;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
deleted file mode 100644
index 7a89baa..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
-import {html} from 'lit';
-import './gr-account-link';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {GrAccountLink} from './gr-account-link';
-import {
-  createAccountWithId,
-  createAccountWithIdNameAndEmail,
-} from '../../../test/test-data-generators';
-import {AccountId, AccountInfo, EmailAddress} from '../../../types/common';
-
-suite('gr-account-link tests', () => {
-  let element: GrAccountLink;
-
-  setup(async () => {
-    const account = createAccountWithIdNameAndEmail();
-    element = await fixture<GrAccountLink>(
-      html`<gr-account-link .account=${account}></gr-account-link>`
-    );
-  });
-
-  test('renders', () => {
-    expect(element).shadowDom.to.equal(/* HTML */ `
-      <span>
-        <a href="">
-          <gr-account-label
-            deselected=""
-            exportparts="gr-account-label-text: gr-account-link-text"
-          >
-          </gr-account-label>
-        </a>
-      </span>
-    `);
-  });
-
-  test('computed fields', () => {
-    const url = 'test/url';
-    const urlStub = sinon.stub(GerritNav, 'getUrlForOwner').returns(url);
-    const account: AccountInfo = {
-      ...createAccountWithId(),
-      email: 'email' as EmailAddress,
-      username: 'username',
-      name: 'name',
-      _account_id: 5 as AccountId,
-    };
-    assert.isNotOk(element._computeOwnerLink());
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
-
-    delete account.email;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
-
-    delete account.username;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
-
-    delete account.name;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('5'));
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 5f0cf7a..3fecd63 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -16,68 +16,70 @@
  */
 import '../gr-account-chip/gr-account-chip';
 import '../gr-account-entry/gr-account-entry';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-list_html';
 import {getAppContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
 import {
   ChangeInfo,
   Suggestion,
   AccountInfo,
   GroupInfo,
   EmailAddress,
+  SuggestedReviewerGroupInfo,
+  SuggestedReviewerAccountInfo,
+  SuggestedReviewerInfo,
 } from '../../../types/common';
-import {
-  ReviewerSuggestionsProvider,
-  SuggestionItem,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
-import {fireAlert} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {accountOrGroupKey} from '../../../utils/account-util';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {classMap} from 'lit/directives/class-map';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
+import {ValueChangedEvent} from '../../../types/events';
+import {queryAndAssert} from '../../../utils/common-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
 declare global {
+  interface HTMLElementEventMap {
+    'accounts-changed': ValueChangedEvent<(AccountInfo | GroupInfo)[]>;
+    'pending-confirmation-changed': ValueChangedEvent<SuggestedReviewerGroupInfo | null>;
+  }
   interface HTMLElementTagNameMap {
     'gr-account-list': GrAccountList;
   }
+  interface HTMLElementEventMap {
+    'account-added': CustomEvent<AccountInputDetail>;
+  }
 }
-
-export interface GrAccountList {
-  $: {
-    entry: GrAccountEntry;
-  };
-}
-
-/**
- * For item added with account info
- */
-export interface AccountObjectInput {
-  account: AccountInfo;
-}
-
-/**
- * For item added with group info
- */
-export interface GroupObjectInput {
-  group: GroupInfo;
-  confirm: boolean;
+export interface AccountInputDetail {
+  account: AccountInput;
 }
 
 /** Supported input to be added */
-export type RawAccountInput = string | AccountObjectInput | GroupObjectInput;
+export type RawAccountInput =
+  | string
+  | SuggestedReviewerAccountInfo
+  | SuggestedReviewerGroupInfo;
 
-// type guards for AccountObjectInput and GroupObjectInput
-function isAccountObject(x: RawAccountInput): x is AccountObjectInput {
-  return !!(x as AccountObjectInput).account;
+// type guards for SuggestedReviewerAccountInfo and SuggestedReviewerGroupInfo
+function isAccountObject(
+  x: RawAccountInput
+): x is SuggestedReviewerAccountInfo {
+  return !!(x as SuggestedReviewerAccountInfo).account;
 }
 
-function isGroupObjectInput(x: RawAccountInput): x is GroupObjectInput {
-  return !!(x as GroupObjectInput).group;
+function isSuggestedReviewerGroupInfo(
+  x: RawAccountInput
+): x is SuggestedReviewerGroupInfo {
+  return !!(x as SuggestedReviewerGroupInfo).group;
 }
 
 // Internal input type with account info
@@ -106,7 +108,7 @@
   return !!input._group || !!input.id;
 }
 
-type AccountInput = AccountInfoInput | GroupInfoInput;
+export type AccountInput = AccountInfoInput | GroupInfoInput;
 
 export interface AccountAddition {
   account?: AccountInfoInput;
@@ -114,18 +116,15 @@
 }
 
 @customElement('gr-account-list')
-export class GrAccountList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountList extends LitElement {
   /**
    * Fired when user inputs an invalid email address.
    *
    * @event show-alert
    */
+  @query('#entry') entry?: GrAccountEntry;
 
-  @property({type: Array, notify: true})
+  @property({type: Array})
   accounts: AccountInput[] = [];
 
   @property({type: Object})
@@ -134,7 +133,7 @@
   @property({type: Object})
   filter?: (input: Suggestion) => boolean;
 
-  @property({type: String})
+  @property()
   placeholder = '';
 
   @property({type: Boolean})
@@ -149,8 +148,8 @@
   /**
    * Needed for template checking since value is initially set to null.
    */
-  @property({type: Object, notify: true})
-  pendingConfirmation: GroupObjectInput | null = null;
+  @property({type: Object})
+  pendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @property({type: Boolean})
   readonly = false;
@@ -158,7 +157,7 @@
   /**
    * When true, allows for non-suggested inputs to be added.
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'allow-any-input'})
   allowAnyInput = false;
 
   /**
@@ -168,14 +167,10 @@
   @property({type: Array})
   removableValues?: AccountInput[];
 
-  @property({type: Number})
-  maxCount = 0;
-
   /**
    * Returns suggestion items
    */
-  @property({type: Object})
-  _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
+  @state() private querySuggestions: AutocompleteQuery<SuggestedReviewerInfo>;
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -183,21 +178,93 @@
 
   constructor() {
     super();
-    this._querySuggestions = input => this._getSuggestions(input);
+    this.querySuggestions = input => this.getSuggestions(input);
     this.addEventListener('remove', e =>
-      this._handleRemove(e as CustomEvent<{account: AccountInput}>)
+      this.handleRemove(e as CustomEvent<{account: AccountInput}>)
     );
   }
 
-  get accountChips() {
-    return Array.from(this.root?.querySelectorAll('gr-account-chip') || []);
+  static override styles = [
+    sharedStyles,
+    css`
+      gr-account-chip {
+        display: inline-block;
+        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+      }
+      gr-account-entry {
+        display: flex;
+        flex: 1;
+        min-width: 10em;
+        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+      }
+      .group {
+        --account-label-suffix: ' (group)';
+      }
+      .pendingAdd {
+        font-style: italic;
+      }
+      .list {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="list">
+        ${this.accounts.map(
+          account => html`
+            <gr-account-chip
+              .account=${account}
+              class=${classMap({
+                group: !!account._group,
+                pendingAdd: !!account._pendingAdd,
+              })}
+              ?removable=${this.computeRemovable(account)}
+              @keydown=${this.handleChipKeydown}
+              tabindex="-1"
+            >
+            </gr-account-chip>
+          `
+        )}
+      </div>
+      <gr-account-entry
+        borderless=""
+        ?hidden=${this.readonly}
+        id="entry"
+        .placeholder=${this.placeholder}
+        @add=${this.handleAdd}
+        @keydown=${this.handleInputKeydown}
+        .allowAnyInput=${this.allowAnyInput}
+        .querySuggestions=${this.querySuggestions}
+      >
+      </gr-account-entry>
+      <slot></slot>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('pendingConfirmation')) {
+      fire(this, 'pending-confirmation-changed', {
+        value: this.pendingConfirmation,
+      });
+    }
+  }
+
+  get accountChips(): GrAccountChip[] {
+    return Array.from(
+      this.shadowRoot?.querySelectorAll('gr-account-chip') || []
+    );
   }
 
   get focusStart() {
-    return this.$.entry.focusStart;
+    // Entry is always defined and we cannot return undefined.
+    return this.entry?.focusStart;
   }
 
-  _getSuggestions(input: string) {
+  getSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion<SuggestedReviewerInfo>[]> {
     const provider = this.suggestionsProvider;
     if (!provider) return Promise.resolve([]);
     return provider.getSuggestions(input).then(suggestions => {
@@ -211,60 +278,70 @@
     });
   }
 
-  _handleAdd(e: CustomEvent<{value: RawAccountInput}>) {
-    this.addAccountItem(e.detail.value);
+  // private but used in test
+  handleAdd(e: ValueChangedEvent<string>) {
+    // TODO(TS) this is temporary hack to avoid cascade of ts issues
+    const item = e.detail.value as RawAccountInput;
+    this.addAccountItem(item);
   }
 
   addAccountItem(item: RawAccountInput) {
     // Append new account or group to the accounts property. We add our own
     // internal properties to the account/group here, so we clone the object
     // to avoid cluttering up the shared change object.
+    let account;
+    let group;
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
-      const account = {...item.account, _pendingAdd: true};
+      account = {...item.account, _pendingAdd: true};
       this.removeFromPendingRemoval(account);
-      this.push('accounts', account);
+      this.accounts.push(account);
       itemTypeAdded = 'account';
-    } else if (isGroupObjectInput(item)) {
+    } else if (isSuggestedReviewerGroupInfo(item)) {
       if (item.confirm) {
         this.pendingConfirmation = item;
         return;
       }
-      const group = {...item.group, _pendingAdd: true, _group: true};
-      this.push('accounts', group);
+      group = {...item.group, _pendingAdd: true, _group: true};
+      this.accounts.push(group);
       this.removeFromPendingRemoval(group);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
         // Repopulate the input with what the user tried to enter and have
         // a toast tell them why they can't enter it.
-        this.$.entry.setText(item);
+        this.entry?.setText(item);
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        const account = {email: item as EmailAddress, _pendingAdd: true};
-        this.push('accounts', account);
+        account = {email: item as EmailAddress, _pendingAdd: true};
+        this.accounts.push(account);
         this.removeFromPendingRemoval(account);
         itemTypeAdded = 'email';
       }
     }
-
+    fire(this, 'accounts-changed', {value: this.accounts.slice()});
+    fire(this, 'account-added', {account: (account ?? group)! as AccountInput});
     this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
     this.pendingConfirmation = null;
+    this.requestUpdate();
     return true;
   }
 
   confirmGroup(group: GroupInfo) {
-    this.push('accounts', {
+    this.accounts.push({
       ...group,
       confirmed: true,
       _pendingAdd: true,
       _group: true,
     });
     this.pendingConfirmation = null;
+    fire(this, 'accounts-changed', {value: this.accounts});
+    this.requestUpdate();
   }
 
-  _computeChipClass(account: AccountInput) {
+  // private but used in test
+  computeChipClass(account: AccountInput) {
     const classes = [];
     if (account._group) {
       classes.push('group');
@@ -275,8 +352,9 @@
     return classes.join(' ');
   }
 
-  _computeRemovable(account: AccountInput, readonly: boolean) {
-    if (readonly) {
+  // private but used in test
+  computeRemovable(account: AccountInput) {
+    if (this.readonly) {
       return false;
     }
     if (this.removableValues) {
@@ -293,21 +371,23 @@
     return true;
   }
 
-  _handleRemove(e: CustomEvent<{account: AccountInput}>) {
+  private handleRemove(e: CustomEvent<{account: AccountInput}>) {
     const toRemove = e.detail.account;
     this.removeAccount(toRemove);
-    this.$.entry.focus();
+    this.entry?.focus();
   }
 
   removeAccount(toRemove?: AccountInput) {
-    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+    if (!toRemove || !this.computeRemovable(toRemove)) {
       return;
     }
     for (let i = 0; i < this.accounts.length; i++) {
       if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
-        this.splice('accounts', i, 1);
+        this.accounts.splice(i, 1);
         this.pendingRemoval.add(toRemove);
         this.reporting.reportInteraction(`Remove from ${this.id}`);
+        this.requestUpdate();
+        fire(this, 'accounts-changed', {value: this.accounts.slice()});
         return;
       }
     }
@@ -316,23 +396,24 @@
     );
   }
 
-  _getNativeInput(paperInput: PaperInputElementExt) {
+  // private but used in test
+  getOwnNativeInput(paperInput: PaperInputElementExt) {
     // In Polymer 2 inputElement isn't nativeInput anymore
     return (paperInput.$.nativeInput ||
       paperInput.inputElement) as HTMLTextAreaElement;
   }
 
-  _handleInputKeydown(
-    e: CustomEvent<{input: PaperInputElementExt; keyCode: number}>
-  ) {
-    const input = this._getNativeInput(e.detail.input);
+  private handleInputKeydown(e: KeyboardEvent) {
+    const target = e.target as GrAccountEntry;
+    const entryInput = queryAndAssert<GrAutocomplete>(target, '#input');
+    const input = this.getOwnNativeInput(entryInput.input!);
     if (
       input.selectionStart !== input.selectionEnd ||
       input.selectionStart !== 0
     ) {
       return;
     }
-    switch (e.detail.keyCode) {
+    switch (e.keyCode) {
       case 8: // Backspace
         this.removeAccount(this.accounts[this.accounts.length - 1]);
         break;
@@ -344,7 +425,7 @@
     }
   }
 
-  _handleChipKeydown(e: KeyboardEvent) {
+  private handleChipKeydown(e: KeyboardEvent) {
     const chip = e.target as GrAccountChip;
     const chips = this.accountChips;
     const index = chips.indexOf(chip);
@@ -362,7 +443,7 @@
         } else if (index > 0) {
           chips[index - 1].focus();
         } else {
-          this.$.entry.focus();
+          this.entry?.focus();
         }
         break;
       case 37: // Left arrow
@@ -376,7 +457,7 @@
         if (index < chips.length - 1) {
           chips[index + 1].focus();
         } else {
-          this.$.entry.focus();
+          this.entry?.focus();
         }
         break;
     }
@@ -391,13 +472,13 @@
    * return true.
    */
   submitEntryText() {
-    const text = this.$.entry.getText();
-    if (!text.length) {
+    const text = this.entry?.getText();
+    if (!text?.length) {
       return true;
     }
     const wasSubmitted = this.addAccountItem(text);
     if (wasSubmitted) {
-      this.$.entry.clear();
+      this.entry?.clear();
     }
     return wasSubmitted;
   }
@@ -428,19 +509,11 @@
     });
   }
 
-  removeFromPendingRemoval(account: AccountInput) {
+  private removeFromPendingRemoval(account: AccountInput) {
     this.pendingRemoval.delete(account);
   }
 
   clearPendingRemovals() {
     this.pendingRemoval.clear();
   }
-
-  _computeEntryHidden(
-    maxCount: number,
-    accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
-    readonly: boolean
-  ) {
-    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
deleted file mode 100644
index 7a47e29..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-account-chip {
-      display: inline-block;
-      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-    }
-    gr-account-entry {
-      display: flex;
-      flex: 1;
-      min-width: 10em;
-      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-    }
-    .group {
-      --account-label-suffix: ' (group)';
-    }
-    .pending-add {
-      font-style: italic;
-    }
-    .list {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-  </style>
-  <!--
-      NOTE(Issue 6419): Nest the inner dom-repeat template in a div rather than
-      as a direct child of the dom-module's template.
-    -->
-  <div class="list">
-    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
-      <gr-account-chip
-        account="[[account]]"
-        class$="[[_computeChipClass(account)]]"
-        data-account-id$="[[account._account_id]]"
-        removable="[[_computeRemovable(account, readonly)]]"
-        on-keydown="_handleChipKeydown"
-        tabindex="-1"
-      >
-      </gr-account-chip>
-    </template>
-  </div>
-  <gr-account-entry
-    borderless=""
-    hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
-    id="entry"
-    placeholder="[[placeholder]]"
-    on-add="_handleAdd"
-    on-input-keydown="_handleInputKeydown"
-    allow-any-input="[[allowAnyInput]]"
-    query-suggestions="[[_querySuggestions]]"
-  >
-  </gr-account-entry>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 23e5a72..26566a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -25,14 +25,20 @@
   AccountId,
   AccountInfo,
   EmailAddress,
+  GroupBaseInfo,
   GroupId,
-  GroupInfo,
-  SuggestedReviewerAccountInfo,
+  GroupName,
+  SuggestedReviewerInfo,
   Suggestion,
 } from '../../../types/common';
-import {queryAll} from '../../../test/test-utils';
+import {queryAll, queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
+import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
@@ -43,7 +49,9 @@
     return Promise.resolve([]);
   }
 
-  makeSuggestionItem(_: Suggestion) {
+  makeSuggestionItem(
+    _: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
     return {
       name: 'test',
       value: {
@@ -51,7 +59,7 @@
           _account_id: 1 as AccountId,
         } as AccountInfo,
         count: 1,
-      } as SuggestedReviewerAccountInfo,
+      },
     };
   }
 }
@@ -64,11 +72,12 @@
       _account_id: accountId as AccountId,
     };
   };
-  const makeGroup: () => GroupInfo = function () {
+  const makeGroup: () => GroupBaseInfo = function () {
     const groupId = `group${++_nextAccountId}`;
     return {
       id: groupId as GroupId,
       _group: true,
+      name: 'abcd' as GroupName,
     };
   };
 
@@ -83,12 +92,14 @@
   }
 
   function handleAdd(value: RawAccountInput) {
-    element._handleAdd(
-      new CustomEvent<{value: RawAccountInput}>('add', {detail: {value}})
+    element.handleAdd(
+      new CustomEvent<{value: string}>('add', {
+        detail: {value: value as unknown as string},
+      })
     );
   }
 
-  setup(() => {
+  setup(async () => {
     existingAccount1 = makeAccount();
     existingAccount2 = makeAccount();
 
@@ -96,18 +107,37 @@
     element.accounts = [existingAccount1, existingAccount2];
     suggestionsProvider = new MockSuggestionsProvider();
     element.suggestionsProvider = suggestionsProvider;
+    await element.updateComplete;
   });
 
-  test('account entry only appears when editable', () => {
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(
+      /* HTML */
+      `<div class="list">
+          <gr-account-chip removable="" tabindex="-1"> </gr-account-chip>
+          <gr-account-chip removable="" tabindex="-1"> </gr-account-chip>
+        </div>
+        <gr-account-entry borderless="" id="entry"></gr-account-entry>
+        <slot></slot>`
+    );
+  });
+
+  test('account entry only appears when editable', async () => {
     element.readonly = false;
-    assert.isFalse(element.$.entry.hasAttribute('hidden'));
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
+    );
     element.readonly = true;
-    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
+    );
   });
 
-  test('addition and removal of account/group chips', () => {
-    flush();
-    sinon.stub(element, '_computeRemovable').returns(true);
+  test('addition and removal of account/group chips', async () => {
+    await element.updateComplete;
+    sinon.stub(element, 'computeRemovable').returns(true);
     // Existing accounts are listed.
     let chips = getChips();
     assert.equal(chips.length, 2);
@@ -116,8 +146,8 @@
 
     // New accounts are added to end with pendingAdd class.
     const newAccount = makeAccount();
-    handleAdd({account: newAccount});
-    flush();
+    handleAdd({account: newAccount, count: 1});
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 3);
     assert.isFalse(chips[0].classList.contains('pendingAdd'));
@@ -132,7 +162,7 @@
         bubbles: true,
       })
     );
-    flush();
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
     assert.isFalse(chips[0].classList.contains('pendingAdd'));
@@ -153,15 +183,15 @@
         bubbles: true,
       })
     );
-    flush();
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
     assert.isFalse(chips[0].classList.contains('pendingAdd'));
 
     // New groups are added to end with pendingAdd and group classes.
     const newGroup = makeGroup();
-    handleAdd({group: newGroup, confirm: false});
-    flush();
+    handleAdd({group: newGroup, confirm: false, count: 1});
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
     assert.isTrue(chips[1].classList.contains('group'));
@@ -175,13 +205,13 @@
         bubbles: true,
       })
     );
-    flush();
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
     assert.isFalse(chips[0].classList.contains('pendingAdd'));
   });
 
-  test('_getSuggestions uses filter correctly', () => {
+  test('getSuggestions uses filter correctly', () => {
     const originalSuggestions: Suggestion[] = [
       {
         email: 'abc@example.com' as EmailAddress,
@@ -215,7 +245,7 @@
       });
 
     return element
-      ._getSuggestions('')
+      .getSuggestions('')
       .then(suggestions => {
         // Default is no filtering.
         assert.equal(suggestions.length, 3);
@@ -226,7 +256,7 @@
           return (suggestion as AccountInfo)._account_id === accountId;
         };
 
-        return element._getSuggestions('');
+        return element.getSuggestions('');
       })
       .then(suggestions => {
         assert.deepEqual(suggestions, [
@@ -241,46 +271,55 @@
       });
   });
 
-  test('_computeChipClass', () => {
+  test('computeChipClass', () => {
     const account = makeAccount() as AccountInfoInput;
-    assert.equal(element._computeChipClass(account), '');
+    assert.equal(element.computeChipClass(account), '');
     account._pendingAdd = true;
-    assert.equal(element._computeChipClass(account), 'pendingAdd');
+    assert.equal(element.computeChipClass(account), 'pendingAdd');
     account._group = true;
-    assert.equal(element._computeChipClass(account), 'group pendingAdd');
+    assert.equal(element.computeChipClass(account), 'group pendingAdd');
     account._pendingAdd = false;
-    assert.equal(element._computeChipClass(account), 'group');
+    assert.equal(element.computeChipClass(account), 'group');
   });
 
-  test('_computeRemovable', () => {
+  test('computeRemovable', async () => {
     const newAccount = makeAccount() as AccountInfoInput;
     newAccount._pendingAdd = true;
     element.readonly = false;
     element.removableValues = [];
-    assert.isFalse(element._computeRemovable(existingAccount1, false));
-    assert.isTrue(element._computeRemovable(newAccount, false));
+    element.updateComplete;
+    assert.isFalse(element.computeRemovable(existingAccount1));
+    assert.isTrue(element.computeRemovable(newAccount));
 
     element.removableValues = [existingAccount1];
-    assert.isTrue(element._computeRemovable(existingAccount1, false));
-    assert.isTrue(element._computeRemovable(newAccount, false));
-    assert.isFalse(element._computeRemovable(existingAccount2, false));
+    element.updateComplete;
+    assert.isTrue(element.computeRemovable(existingAccount1));
+    assert.isTrue(element.computeRemovable(newAccount));
+    assert.isFalse(element.computeRemovable(existingAccount2));
 
     element.readonly = true;
-    assert.isFalse(element._computeRemovable(existingAccount1, true));
-    assert.isFalse(element._computeRemovable(newAccount, true));
+    element.updateComplete;
+    assert.isFalse(element.computeRemovable(existingAccount1));
+    assert.isFalse(element.computeRemovable(newAccount));
   });
 
-  test('submitEntryText', () => {
+  test('submitEntryText', async () => {
     element.allowAnyInput = true;
-    flush();
+    await element.updateComplete;
 
-    const getTextStub = sinon.stub(element.$.entry, 'getText');
+    const getTextStub = sinon.stub(
+      queryAndAssert<GrAccountEntry>(element, '#entry'),
+      'getText'
+    );
     getTextStub.onFirstCall().returns('');
     getTextStub.onSecondCall().returns('test');
     getTextStub.onThirdCall().returns('test@test');
 
     // When entry is empty, return true.
-    const clearStub = sinon.stub(element.$.entry, 'clear');
+    const clearStub = sinon.stub(
+      queryAndAssert<GrAccountEntry>(element, '#entry'),
+      'clear'
+    );
     assert.isTrue(element.submitEntryText());
     assert.isFalse(clearStub.called);
 
@@ -301,9 +340,9 @@
     assert.equal(element.additions().length, 0);
 
     const newAccount = makeAccount();
-    handleAdd({account: newAccount});
+    handleAdd({account: newAccount, count: 1});
     const newGroup = makeGroup();
-    handleAdd({group: newGroup, confirm: false});
+    handleAdd({group: newGroup, confirm: false, count: 1});
 
     assert.deepEqual(element.additions(), [
       {
@@ -317,6 +356,7 @@
           id: newGroup.id,
           _group: true,
           _pendingAdd: true,
+          name: 'abcd' as GroupName,
         },
       },
     ]);
@@ -346,6 +386,7 @@
           _group: true,
           _pendingAdd: true,
           confirmed: true,
+          name: 'abcd' as GroupName,
         },
       },
     ]);
@@ -359,14 +400,6 @@
     assert.equal(element.accounts.length, 1);
   });
 
-  test('max-count', () => {
-    element.maxCount = 1;
-    const acct = makeAccount();
-    handleAdd({account: acct});
-    flush();
-    assert.isTrue(element.$.entry.hasAttribute('hidden'));
-  });
-
   test('enter text calls suggestions provider', async () => {
     const suggestions: Suggestion[] = [
       {
@@ -387,15 +420,17 @@
       'makeSuggestionItem'
     );
 
-    const input = element.$.entry.$.input;
-
+    const input = queryAndAssert<GrAutocomplete>(
+      queryAndAssert<GrAccountEntry>(element, '#entry'),
+      '#input'
+    );
     input.text = 'newTest';
-    MockInteractions.focus(input.$.input);
+    MockInteractions.focus(input.input!);
     input.noDebounce = true;
-    await flush();
+    await element.updateComplete;
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
+    await waitUntil(() => makeSuggestionItemSpy.getCalls().length === 2);
   });
 
   suite('allowAnyInput', () => {
@@ -423,41 +458,47 @@
 
   suite('keyboard interactions', () => {
     test('backspace at text input start removes last account', async () => {
-      const input = element.$.entry.$.input;
-      sinon.stub(input, '_updateSuggestions');
-      sinon.stub(element, '_computeRemovable').returns(true);
-      await flush();
+      const input = queryAndAssert<GrAutocomplete>(
+        queryAndAssert<GrAccountEntry>(element, '#entry'),
+        '#input'
+      );
+      sinon.stub(input, 'updateSuggestions');
+      sinon.stub(element, 'computeRemovable').returns(true);
+      await element.updateComplete;
       // Next line is a workaround for Firefox not moving cursor
       // on input field update
-      assert.equal(element._getNativeInput(input.$.input).selectionStart, 0);
+      assert.equal(element.getOwnNativeInput(input.input!).selectionStart, 0);
       input.text = 'test';
-      MockInteractions.focus(input.$.input);
-      flush();
+      MockInteractions.focus(input.input!);
+      await element.updateComplete;
       assert.equal(element.accounts.length, 2);
       MockInteractions.pressAndReleaseKeyOn(
-        element._getNativeInput(input.$.input),
+        element.getOwnNativeInput(input.input!),
         8
       ); // Backspace
-      assert.equal(element.accounts.length, 2);
+      await waitUntil(() => element.accounts.length === 2);
       input.text = '';
+      await input.updateComplete;
       MockInteractions.pressAndReleaseKeyOn(
-        element._getNativeInput(input.$.input),
+        element.getOwnNativeInput(input.input!),
         8
       ); // Backspace
-      flush();
-      assert.equal(element.accounts.length, 1);
+      await waitUntil(() => element.accounts.length === 1);
     });
 
     test('arrow key navigation', async () => {
-      const input = element.$.entry.$.input;
+      const input = queryAndAssert<GrAutocomplete>(
+        queryAndAssert<GrAccountEntry>(element, '#entry'),
+        '#input'
+      );
       input.text = '';
       element.accounts = [makeAccount(), makeAccount()];
-      flush();
-      MockInteractions.focus(input.$.input);
-      await flush();
+      await element.updateComplete;
+      MockInteractions.focus(input.input!);
+      await element.updateComplete;
       const chips = element.accountChips;
       const chipsOneSpy = sinon.spy(chips[1], 'focus');
-      MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+      MockInteractions.pressAndReleaseKeyOn(input.input!, 37); // Left
       assert.isTrue(chipsOneSpy.called);
       const chipsZeroSpy = sinon.spy(chips[0], 'focus');
       MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
@@ -468,9 +509,9 @@
       assert.isTrue(chipsOneSpy.calledTwice);
     });
 
-    test('delete', () => {
+    test('delete', async () => {
       element.accounts = [makeAccount(), makeAccount()];
-      flush();
+      await element.updateComplete;
       const focusSpy = sinon.spy(element.accountChips[1], 'focus');
       const removeSpy = sinon.spy(element, 'removeAccount');
       MockInteractions.pressAndReleaseKeyOn(element.accountChips[0], 8); // Backspace
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index fa547dc..50ca7a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -99,7 +99,7 @@
         <gr-button
           link=""
           class="action"
-          ?hidden="${this._hideActionButton}"
+          ?hidden=${this._hideActionButton}
           @click=${this._handleActionTap}
           >${actionText}
         </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 7216502..97b6be1 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -55,6 +55,10 @@
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior);
 
+/**
+ * @attr {String} vertical-align - inherited from IronOverlay
+ * @attr {String} horizontal-align - inherited from IronOverlay
+ */
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends base {
   static get template() {
@@ -132,9 +136,7 @@
 
   open() {
     this.isHidden = false;
-    this._resetCursorStops();
-    // Refit should run after we call Polymer.flush inside _resetCursorStops
-    this.refit();
+    this.onSuggestionsChanged();
   }
 
   getCurrentText() {
@@ -219,7 +221,7 @@
   }
 
   @observe('suggestions')
-  _resetCursorStops() {
+  onSuggestionsChanged() {
     if (this.suggestions.length > 0) {
       if (!this.isHidden) {
         flush();
@@ -231,6 +233,7 @@
     } else {
       this.cursor.stops = [];
     }
+    this.refit();
   }
 
   @observe('index')
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 86de3b3..f8478cd 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -135,7 +135,7 @@
   });
 
   test('updated suggestions resets cursor stops', () => {
-    const resetStopsSpy = sinon.spy(element, '_resetCursorStops');
+    const resetStopsSpy = sinon.spy(element, 'onSuggestionsChanged');
     element.suggestions = [];
     assert.isTrue(resetStopsSpy.called);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 4be42d8..efcb751 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -19,27 +19,21 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-autocomplete_html';
-import {property, customElement, observe} from '@polymer/decorators';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElementExt} from '../../../types/types';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {PropertyType} from '../../../types/common';
 import {modifierPressed} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {ValueChangedEvent} from '../../../types/events';
+import {IronInputElement} from '@polymer/iron-input';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
 
-export interface GrAutocomplete {
-  $: {
-    input: PaperInputElementExt;
-    suggestions: GrAutocompleteDropdown;
-  };
-}
-
 export type AutocompleteQuery<T = string> = (
   text: string
 ) => Promise<Array<AutocompleteSuggestion<T>>>;
@@ -48,13 +42,17 @@
   interface HTMLElementTagNameMap {
     'gr-autocomplete': GrAutocomplete;
   }
+  interface HTMLElementEventMap {
+    'text-changed': ValueChangedEvent<string>;
+    'value-changed': ValueChangedEvent<string>;
+  }
 }
 
 export interface AutocompleteSuggestion<T = string> {
   name?: string;
   label?: string;
   value?: T;
-  text?: T;
+  text?: string;
 }
 
 export interface AutocompleteCommitEventDetail {
@@ -65,10 +63,7 @@
   CustomEvent<AutocompleteCommitEventDetail>;
 
 @customElement('gr-autocomplete')
-export class GrAutocomplete extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAutocomplete extends LitElement {
   /**
    * Fired when a value is chosen.
    *
@@ -101,6 +96,10 @@
   @property({type: Object})
   query?: AutocompleteQuery = () => Promise.resolve([]);
 
+  @query('#input') input?: PaperInputElementExt;
+
+  @query('#suggestions') suggestionsDropdown?: GrAutocompleteDropdown;
+
   /**
    * The number of characters that must be typed before suggestions are
    * made. If threshold is zero, default suggestions are enabled.
@@ -108,7 +107,7 @@
   @property({type: Number})
   threshold = 1;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'allow-non-suggested-values'})
   allowNonSuggestedValues = false;
 
   @property({type: Boolean})
@@ -117,7 +116,7 @@
   @property({type: Boolean})
   disabled = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-search-icon'})
   showSearchIcon = false;
 
   /**
@@ -129,13 +128,13 @@
   @property({type: Number})
   verticalOffset = 31;
 
-  @property({type: String, notify: true})
+  @property({type: String})
   text = '';
 
   @property({type: String})
   placeholder = '';
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'clear-on-commit'})
   clearOnCommit = false;
 
   /**
@@ -143,10 +142,10 @@
    * When false, tab key not caught, and focus is removed from the element.
    * See Issue 4556, Issue 6645.
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'tab-complete'})
   tabComplete = false;
 
-  @property({type: String, notify: true})
+  @property({type: String})
   value = '';
 
   /**
@@ -160,29 +159,17 @@
    * When true and uncommitted text is left in the autocomplete input after
    * blurring, the text will appear red.
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'warn-uncommitted'})
   warnUncommitted = false;
 
   /**
    * When true, querying for suggestions is not debounced w/r/t keypresses
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'no-debounce'})
   noDebounce = false;
 
-  @property({type: Array})
-  _suggestions: AutocompleteSuggestion[] = [];
-
-  @property({type: Array})
-  _suggestionEls = [];
-
-  @property({type: Number})
-  _index: number | null = null;
-
-  @property({type: Boolean})
-  _disableSuggestions = false;
-
-  @property({type: Boolean})
-  _focused = false;
+  @property({type: Boolean, attribute: 'show-blue-focus-border'})
+  showBlueFocusBorder = false;
 
   /**
    * Invisible label for input element. This label is exposed to
@@ -191,18 +178,88 @@
   @property({type: String})
   label = '';
 
-  /** The DOM element of the selected suggestion. */
-  @property({type: Object})
-  _selected: HTMLElement | null = null;
+  @state() suggestions: AutocompleteSuggestion[] = [];
+
+  @state() index: number | null = null;
+
+  @state() disableSuggestions = false;
+
+  // private but used in tests
+  focused = false;
+
+  @state() selected: HTMLElement | null = null;
 
   private updateSuggestionsTask?: DelayedTask;
 
-  get _nativeInput() {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return (this.$.input.$.nativeInput ||
-      this.$.input.inputElement) as HTMLInputElement;
+  get nativeInput() {
+    return (this.input!.inputElement as IronInputElement)
+      .inputElement as HTMLInputElement;
   }
 
+  static override styles = [
+    sharedStyles,
+    css`
+      .searchIcon {
+        display: none;
+      }
+      .searchIcon.showSearchIcon {
+        display: inline-block;
+      }
+      iron-icon {
+        margin: 0 var(--spacing-xs);
+        vertical-align: top;
+      }
+      paper-input.borderless {
+        border: none;
+        padding: 0;
+      }
+      paper-input {
+        background-color: var(--view-background-color);
+        color: var(--primary-text-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s);
+        --paper-input-container_-_padding: 0;
+        --paper-input-container-input_-_font-size: var(--font-size-normal);
+        --paper-input-container-input_-_line-height: var(--line-height-normal);
+        /* This is a hack for not being able to set height:0 on the underline
+            of a paper-input 2.2.3 element. All the underline fixes below only
+            actually work in 3.x.x, so the height must be adjusted directly as
+            a workaround until we are on Polymer 3. */
+        height: var(--line-height-normal);
+        --paper-input-container-underline-height: 0;
+        --paper-input-container-underline-wrapper-height: 0;
+        --paper-input-container-underline-focus-height: 0;
+        --paper-input-container-underline-legacy-height: 0;
+        --paper-input-container-underline_-_height: 0;
+        --paper-input-container-underline_-_display: none;
+        --paper-input-container-underline-focus_-_height: 0;
+        --paper-input-container-underline-focus_-_display: none;
+        --paper-input-container-underline-disabled_-_height: 0;
+        --paper-input-container-underline-disabled_-_display: none;
+        /* Hide label for input. The label is still visible for
+           screen readers. Workaround found at:
+           https://github.com/PolymerElements/paper-input/issues/478 */
+        --paper-input-container-label_-_display: none;
+      }
+      paper-input.showBlueFocusBorder:focus {
+        border: 2px solid var(--input-focus-border-color);
+        /*
+         * The goal is to have a thicker blue border when focused and a thinner
+         * gray border when blurred. To avoid shifting neighboring elements
+         * around when the border size changes, a negative margin is added to
+         * compensate. box-sizing: border-box; will not work since there is
+         * important padding to add around the content.
+         */
+        margin: -1px;
+      }
+      paper-input.warnUncommitted {
+        --paper-input-container-input_-_color: var(--error-text-color);
+        --paper-input-container-input_-_font-size: inherit;
+      }
+    `,
+  ];
+
   override connectedCallback() {
     super.connectedCallback();
     document.addEventListener('click', this.handleBodyClick);
@@ -214,98 +271,163 @@
     super.disconnectedCallback();
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('text') ||
+      changedProperties.has('threshold') ||
+      changedProperties.has('noDebounce')
+    ) {
+      this.updateSuggestions();
+    }
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('focused')
+    ) {
+      this.updateDropdownVisibility();
+    }
+    if (changedProperties.has('text')) {
+      fire(this, 'text-changed', {value: this.text});
+    }
+    if (changedProperties.has('value')) {
+      fire(this, 'value-changed', {value: this.value});
+    }
+  }
+
+  override render() {
+    return html`
+      <paper-input
+        .noLabelFloat=${true}
+        id="input"
+        class=${this.computeClass()}
+        ?disabled=${this.disabled}
+        .value=${this.text}
+        @value-changed=${(e: CustomEvent) => {
+          this.text = e.detail.value;
+        }}
+        .placeholder=${this.placeholder}
+        @keydown=${this.handleKeydown}
+        @focus=${this.onInputFocus}
+        @blur=${this.onInputBlur}
+        autocomplete="off"
+        .label=${this.label}
+      >
+        <div slot="prefix">
+          <iron-icon
+            icon="gr-icons:search"
+            class="searchIcon ${this.computeShowSearchIconClass(
+              this.showSearchIcon
+            )}"
+          >
+          </iron-icon>
+        </div>
+
+        <div slot="suffix">
+          <slot name="suffix"></slot>
+        </div>
+      </paper-input>
+      <gr-autocomplete-dropdown
+        vertical-align="top"
+        .verticalOffset=${this.verticalOffset}
+        horizontal-align="left"
+        id="suggestions"
+        @item-selected=${this.handleItemSelect}
+        .suggestions=${this.suggestions}
+        role="listbox"
+        .index=${this.index}
+      >
+      </gr-autocomplete-dropdown>
+    `;
+  }
+
   get focusStart() {
-    return this.$.input;
+    return this.input;
   }
 
   override focus() {
-    this._nativeInput.focus();
+    this.nativeInput.focus();
   }
 
   selectAll() {
-    const nativeInputElement = this._nativeInput;
-    if (!this.$.input.value) {
+    const nativeInputElement = this.nativeInput;
+    if (!this.input?.value) {
       return;
     }
-    nativeInputElement.setSelectionRange(0, this.$.input.value.length);
+    nativeInputElement.setSelectionRange(0, this.input?.value.length);
   }
 
   clear() {
     this.text = '';
   }
 
-  _handleItemSelect(e: CustomEvent) {
+  handleItemSelect(e: CustomEvent) {
     if (e.detail.trigger === 'click') {
-      this._selected = e.detail.selected;
+      this.selected = e.detail.selected;
       this._commit();
       e.stopPropagation();
       e.preventDefault();
     } else if (e.detail.trigger === 'enter') {
-      this._handleInputCommit();
+      this.handleInputCommit();
       e.stopPropagation();
       e.preventDefault();
     } else if (e.detail.trigger === 'tab') {
       if (this.tabComplete) {
-        this._handleInputCommit(true);
+        this.handleInputCommit(true);
         e.stopPropagation();
         e.preventDefault();
         this.focus();
       } else {
-        this._focused = false;
+        this.setFocus(false);
       }
     }
   }
 
-  get _inputElement() {
-    // Polymer2: this.$ can be undefined when this is first evaluated.
-    return this.$ && this.$.input;
-  }
-
   /**
    * Set the text of the input without triggering the suggestion dropdown.
    *
    * @param text The new text for the input.
    */
-  setText(text: string) {
-    this._disableSuggestions = true;
+  async setText(text: string) {
+    this.disableSuggestions = true;
     this.text = text;
-    this._disableSuggestions = false;
+    // if we disableSuggestions immediately then suggestions are requested in
+    // updateSuggestions
+    await this.updateComplete;
+    this.disableSuggestions = false;
   }
 
-  _onInputFocus() {
-    this._focused = true;
-    this._updateSuggestions(this.text, this.threshold, this.noDebounce);
-    this.$.input.classList.remove('warnUncommitted');
+  onInputFocus() {
+    this.setFocus(true);
+    this.updateSuggestions();
+    this.input?.classList.remove('warnUncommitted');
     // Needed so that --paper-input-container-input updated style is applied.
-    this.updateStyles();
+    this.requestUpdate();
   }
 
-  _onInputBlur() {
-    this.$.input.classList.toggle(
+  onInputBlur() {
+    this.input?.classList.toggle(
       'warnUncommitted',
-      this.warnUncommitted && !!this.text.length && !this._focused
+      this.warnUncommitted && !!this.text.length && !this.focused
     );
     // Needed so that --paper-input-container-input updated style is applied.
-    this.updateStyles();
+    this.requestUpdate();
   }
 
-  @observe('text', 'threshold', 'noDebounce')
-  _updateSuggestions(text?: string, threshold?: number, noDebounce?: boolean) {
+  updateSuggestions() {
     if (
-      text === undefined ||
-      threshold === undefined ||
-      noDebounce === undefined
+      this.text === undefined ||
+      this.threshold === undefined ||
+      this.noDebounce === undefined
     )
       return;
 
-    // Reset _suggestions for every update
+    // Reset suggestions for every update
     // This will also prevent from carrying over suggestions:
     // @see Issue 12039
-    this._suggestions = [];
+    this.suggestions = [];
 
     // TODO(taoalpha): Also skip if text has not changed
 
-    if (this._disableSuggestions) {
+    if (this.disableSuggestions) {
       return;
     }
 
@@ -314,33 +436,32 @@
       return;
     }
 
-    if (text.length < threshold) {
+    if (this.text.length < this.threshold) {
       this.value = '';
       return;
     }
 
-    if (!this._focused) {
+    if (!this.focused) {
       return;
     }
 
     const update = () => {
-      query(text).then(suggestions => {
-        if (text !== this.text) {
+      query(this.text).then(suggestions => {
+        if (this.text !== this.text) {
           // Late response.
           return;
         }
         for (const suggestion of suggestions) {
-          suggestion.text = suggestion.name;
+          suggestion.text = suggestion?.name ?? '';
         }
-        this._suggestions = suggestions;
-        flush();
-        if (this._index === -1) {
+        this.suggestions = suggestions;
+        if (this.index === -1) {
           this.value = '';
         }
       });
     };
 
-    if (noDebounce) {
+    if (this.noDebounce) {
       update();
     } else {
       this.updateSuggestionsTask = debounce(
@@ -351,43 +472,52 @@
     }
   }
 
-  @observe('_suggestions', '_focused')
-  _maybeOpenDropdown(suggestions: AutocompleteSuggestion[], focused: boolean) {
-    if (suggestions.length > 0 && focused) {
-      return this.$.suggestions.open();
-    }
-    return this.$.suggestions.close();
+  setFocus(focused: boolean) {
+    if (focused === this.focused) return;
+    this.focused = focused;
+    this.updateDropdownVisibility();
   }
 
-  _computeClass(borderless?: boolean) {
-    return borderless ? 'borderless' : '';
+  updateDropdownVisibility() {
+    if (this.suggestions.length > 0 && this.focused) {
+      this.suggestionsDropdown?.open();
+      return;
+    }
+    this.suggestionsDropdown?.close();
+  }
+
+  computeClass() {
+    const classes = [];
+    if (this.borderless) classes.push('borderless');
+    if (this.showBlueFocusBorder) classes.push('showBlueFocusBorder');
+    return classes.join(' ');
   }
 
   /**
-   * _handleKeydown used for key handling in the this.$.input.
+   * handleKeydown used for key handling in the this.input?.
    */
-  _handleKeydown(e: KeyboardEvent) {
-    this._focused = true;
+  handleKeydown(e: KeyboardEvent) {
+    this.setFocus(true);
     switch (e.keyCode) {
       case 38: // Up
         e.preventDefault();
-        this.$.suggestions.cursorUp();
+        this.suggestionsDropdown?.cursorUp();
         break;
       case 40: // Down
         e.preventDefault();
-        this.$.suggestions.cursorDown();
+        this.suggestionsDropdown?.cursorDown();
         break;
       case 27: // Escape
         e.preventDefault();
-        this._cancel();
+        this.cancel();
         break;
       case 9: // Tab
-        if (this._suggestions.length > 0 && this.tabComplete) {
+        if (this.suggestions.length > 0 && this.tabComplete) {
           e.preventDefault();
-          this._handleInputCommit(true);
           this.focus();
+          this.handleInputCommit(true);
         } else {
-          this._focused = false;
+          this.setFocus(false);
         }
         break;
       case 13: // Enter
@@ -395,7 +525,7 @@
           break;
         }
         e.preventDefault();
-        this._handleInputCommit();
+        this.handleInputCommit();
         break;
       default:
         // For any normal keypress, return focus to the input to allow for
@@ -406,36 +536,37 @@
         // been based on a previous input. Clear them. This prevents an
         // outdated suggestion from being used if the input keystroke is
         // immediately followed by a commit keystroke. @see Issue 8655
-        this._suggestions = [];
+        this.suggestions = [];
     }
     this.dispatchEvent(
       new CustomEvent('input-keydown', {
-        detail: {keyCode: e.keyCode, input: this.$.input},
+        detail: {keyCode: e.keyCode, input: this.input},
         composed: true,
         bubbles: true,
       })
     );
   }
 
-  _cancel() {
-    if (this._suggestions.length) {
-      this.set('_suggestions', []);
+  cancel() {
+    if (this.suggestions.length) {
+      this.suggestions = [];
+      this.requestUpdate();
     } else {
       fireEvent(this, 'cancel');
     }
   }
 
-  _handleInputCommit(_tabComplete?: boolean) {
+  handleInputCommit(_tabComplete?: boolean) {
     // Nothing to do if the dropdown is not open.
-    if (!this.allowNonSuggestedValues && this.$.suggestions.isHidden) {
+    if (!this.allowNonSuggestedValues && this.suggestionsDropdown?.isHidden) {
       return;
     }
 
-    this._selected = this.$.suggestions.getCursorTarget();
+    this.selected = this.suggestionsDropdown?.getCursorTarget() ?? null;
     this._commit(_tabComplete);
   }
 
-  _updateValue(
+  updateValue(
     suggestion: HTMLElement | null,
     suggestions: AutocompleteSuggestion[]
   ) {
@@ -467,7 +598,7 @@
         return;
       }
     }
-    this._focused = false;
+    this.setFocus(false);
   };
 
   /**
@@ -477,10 +608,10 @@
    * autocomplete suggestion in order to handle cases like tab-to-complete
    * without firing the commit event.
    */
-  _commit(silent?: boolean) {
+  async _commit(silent?: boolean) {
     // Allow values that are not in suggestion list iff suggestions are empty.
-    if (this._suggestions.length > 0) {
-      this._updateValue(this._selected, this._suggestions);
+    if (this.suggestions.length > 0) {
+      this.updateValue(this.selected, this.suggestions);
     } else {
       this.value = this.text || '';
     }
@@ -489,22 +620,25 @@
 
     // Value and text are mirrors of each other in multi mode.
     if (this.multi) {
-      this.setText(this.value);
+      await this.setText(this.value);
     } else {
-      if (!this.clearOnCommit && this._selected) {
-        const dataSet = this._selected.dataset;
+      if (!this.clearOnCommit && this.selected) {
+        const dataSet = this.selected.dataset;
         // index property cannot be null for the data-set
         if (dataSet) {
           const index = Number(dataSet['index']!);
           if (isNaN(index)) return;
-          this.setText(this._suggestions[index].name || '');
+          await this.setText(this.suggestions[index]?.name || '');
         }
       } else {
         this.clear();
       }
     }
 
-    this._suggestions = [];
+    this.suggestions = [];
+    // we need willUpdate to send text-changed event before we can send the
+    // 'commit' event
+    await this.updateComplete;
     if (!silent) {
       this.dispatchEvent(
         new CustomEvent('commit', {
@@ -516,7 +650,7 @@
     }
   }
 
-  _computeShowSearchIconClass(showSearchIcon: boolean) {
+  computeShowSearchIconClass(showSearchIcon: boolean) {
     return showSearchIcon ? 'showSearchIcon' : '';
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
deleted file mode 100644
index bdb65ea..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .searchIcon {
-      display: none;
-    }
-    .searchIcon.showSearchIcon {
-      display: inline-block;
-    }
-    iron-icon {
-      margin: 0 var(--spacing-xs);
-      vertical-align: top;
-    }
-    paper-input.borderless {
-      border: none;
-      padding: 0;
-    }
-    paper-input {
-      background-color: var(--view-background-color);
-      color: var(--primary-text-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      padding: var(--spacing-s);
-      --paper-input-container: {
-        padding: 0;
-      }
-      --paper-input-container-input: {
-        font-size: var(--font-size-normal);
-        line-height: var(--line-height-normal);
-      }
-      /* This is a hack for not being able to set height:0 on the underline
-           of a paper-input 2.2.3 element. All the underline fixes below only
-           actually work in 3.x.x, so the height must be adjusted directly as
-           a workaround until we are on Polymer 3. */
-      height: var(--line-height-normal);
-      --paper-input-container-underline-height: 0;
-      --paper-input-container-underline-wrapper-height: 0;
-      --paper-input-container-underline-focus-height: 0;
-      --paper-input-container-underline-legacy-height: 0;
-      --paper-input-container-underline: {
-        height: 0;
-        display: none;
-      }
-      --paper-input-container-underline-focus: {
-        height: 0;
-        display: none;
-      }
-      --paper-input-container-underline-disabled: {
-        height: 0;
-        display: none;
-      }
-      /* Hide label for input. The label is still visible for
-      screen readers. Workaround found at:
-      https://github.com/PolymerElements/paper-input/issues/478 */
-      --paper-input-container-label: {
-        display: none;
-      }
-    }
-    paper-input.warnUncommitted {
-      --paper-input-container-input: {
-        color: var(--error-text-color);
-        font-size: inherit;
-      }
-    }
-  </style>
-  <paper-input
-    no-label-float=""
-    id="input"
-    class$="[[_computeClass(borderless)]]"
-    disabled$="[[disabled]]"
-    value="{{text}}"
-    placeholder="[[placeholder]]"
-    on-keydown="_handleKeydown"
-    on-focus="_onInputFocus"
-    on-blur="_onInputBlur"
-    autocomplete="off"
-    label="[[label]]"
-  >
-    <!-- prefix as attribute is required to for polymer 1 -->
-    <div slot="prefix" prefix="">
-      <iron-icon
-        icon="gr-icons:search"
-        class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]"
-      >
-      </iron-icon>
-    </div>
-
-    <!-- suffix as attribute is required to for polymer 1 -->
-    <div slot="suffix" suffix="">
-      <slot name="suffix"></slot>
-    </div>
-  </paper-input>
-  <gr-autocomplete-dropdown
-    vertical-align="top"
-    vertical-offset="[[verticalOffset]]"
-    horizontal-align="left"
-    id="suggestions"
-    on-item-selected="_handleItemSelect"
-    suggestions="[[_suggestions]]"
-    role="listbox"
-    index="[[_index]]"
-    position-target="[[_inputElement]]"
-  >
-  </gr-autocomplete-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index 3571bd2..7f66b60 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -16,18 +16,13 @@
  */
 import '../../../test/common-test-setup-karma';
 import './gr-autocomplete';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {assertIsDefined} from '../../../utils/common-util';
-import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {queryAll, queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
-
-const basicFixture = fixtureFromTemplate(
-  html`<gr-autocomplete no-debounce></gr-autocomplete>`
-);
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-autocomplete tests', () => {
   let element: GrAutocomplete;
@@ -41,11 +36,14 @@
 
   const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
 
-  setup(() => {
-    element = basicFixture.instantiate() as GrAutocomplete;
+  setup(async () => {
+    element = await fixture(
+      html`<gr-autocomplete no-debounce></gr-autocomplete>`
+    );
+    await element.updateComplete;
   });
 
-  test('renders', () => {
+  test('renders', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
       (input: string) =>
@@ -63,12 +61,14 @@
 
     focusOnInput();
     element.text = 'blah';
+    await waitUntil(() => queryStub.called);
 
     assert.isTrue(queryStub.called);
-    element._focused = true;
+    element.setFocus(true);
 
     assertIsDefined(promise);
-    return promise.then(() => {
+    return promise.then(async () => {
+      await element.updateComplete;
       assert.isFalse(suggestionsEl().isHidden);
       const suggestions = queryAll<HTMLElement>(suggestionsEl(), 'li');
       assert.equal(suggestions.length, 5);
@@ -82,19 +82,21 @@
   });
 
   test('selectAll', async () => {
-    await flush();
-    const nativeInput = element._nativeInput;
+    await element.updateComplete;
+    const nativeInput = element.nativeInput;
     const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
 
     element.selectAll();
+    await element.updateComplete;
     assert.isFalse(selectionStub.called);
 
     inputEl().value = 'test';
+    await element.updateComplete;
     element.selectAll();
     assert.isTrue(selectionStub.called);
   });
 
-  test('esc key behavior', () => {
+  test('esc key behavior', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
       (_: string) =>
@@ -106,26 +108,30 @@
 
     assert.isTrue(suggestionsEl().isHidden);
 
-    element._focused = true;
+    element.setFocus(true);
     element.text = 'blah';
+    await element.updateComplete;
 
-    return promise.then(() => {
-      assert.isFalse(suggestionsEl().isHidden);
+    return promise.then(async () => {
+      await waitUntil(() => !suggestionsEl().isHidden);
 
       const cancelHandler = sinon.spy();
       element.addEventListener('cancel', cancelHandler);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      await waitUntil(() => suggestionsEl().isHidden);
+
       assert.isFalse(cancelHandler.called);
-      assert.isTrue(suggestionsEl().isHidden);
-      assert.equal(element._suggestions.length, 0);
+      assert.equal(element.suggestions.length, 0);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      await element.updateComplete;
+
       assert.isTrue(cancelHandler.called);
     });
   });
 
-  test('emits commit and handles cursor movement', () => {
+  test('emits commit and handles cursor movement', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
       (input: string) =>
@@ -138,14 +144,16 @@
         ] as AutocompleteSuggestion[]))
     );
     element.query = queryStub;
-
+    await element.updateComplete;
     assert.isTrue(suggestionsEl().isHidden);
     assert.equal(suggestionsEl().cursor.index, -1);
-    element._focused = true;
-    element.text = 'blah';
+    element.setFocus(true);
 
-    return promise.then(() => {
-      assert.isFalse(suggestionsEl().isHidden);
+    element.text = 'blah';
+    await element.updateComplete;
+
+    return promise.then(async () => {
+      await waitUntil(() => !suggestionsEl().isHidden);
 
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
@@ -153,28 +161,33 @@
       assert.equal(suggestionsEl().cursor.index, 0);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+      await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 1);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+      await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 2);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 38, null, 'up');
+      await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 1);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      await element.updateComplete;
 
       assert.equal(element.value, '1');
-      assert.isTrue(commitHandler.called);
+
+      await waitUntil(() => commitHandler.called);
       assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
       assert.isTrue(suggestionsEl().isHidden);
-      assert.isTrue(element._focused);
+      assert.isTrue(element.focused);
     });
   });
 
-  test('clear-on-commit behavior (off)', () => {
+  test('clear-on-commit behavior (off)', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(() => {
       promise = Promise.resolve([
@@ -185,19 +198,21 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'blah';
+    await waitUntil(() => element.suggestions.length > 0);
 
-    return promise.then(() => {
+    return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
 
-      assert.isTrue(commitHandler.called);
+      await waitUntil(() => commitHandler.called);
+
       assert.equal(element.text, 'suggestion');
     });
   });
 
-  test('clear-on-commit behavior (on)', () => {
+  test('clear-on-commit behavior (on)', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(() => {
       promise = Promise.resolve([
@@ -208,20 +223,24 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'blah';
+
+    await waitUntil(() => element.suggestions.length > 0);
+
     element.clearOnCommit = true;
 
-    return promise.then(() => {
+    return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
 
-      assert.isTrue(commitHandler.called);
+      await waitUntil(() => commitHandler.called);
+
       assert.equal(element.text, '');
     });
   });
 
-  test('threshold guards the query', () => {
+  test('threshold guards the query', async () => {
     const queryStub = sinon.spy(() =>
       Promise.resolve([] as AutocompleteSuggestion[])
     );
@@ -229,17 +248,21 @@
     element.threshold = 2;
     focusOnInput();
     element.text = 'a';
+    await element.updateComplete;
     assert.isFalse(queryStub.called);
+
     element.text = 'ab';
-    assert.isTrue(queryStub.called);
+    await element.updateComplete;
+    await waitUntil(() => queryStub.called);
   });
 
-  test('noDebounce=false debounces the query', () => {
-    const clock = sinon.useFakeTimers();
+  test('noDebounce=false debounces the query', async () => {
     const queryStub = sinon.spy(() =>
       Promise.resolve([] as AutocompleteSuggestion[])
     );
+
     element.query = queryStub;
+    await element.updateComplete;
     element.noDebounce = false;
     focusOnInput();
     element.text = 'a';
@@ -247,23 +270,27 @@
     // not called right away
     assert.isFalse(queryStub.called);
 
-    // but called after a while
-    clock.tick(1000);
-    assert.isTrue(queryStub.called);
+    await waitUntil(() => queryStub.called);
   });
 
-  test('_computeClass respects border property', () => {
-    assert.equal(element._computeClass(), '');
-    assert.equal(element._computeClass(false), '');
-    assert.equal(element._computeClass(true), 'borderless');
+  test('computeClass respects border property', () => {
+    element.borderless = false;
+    assert.equal(element.computeClass(), '');
+    element.borderless = true;
+    assert.equal(element.computeClass(), 'borderless');
+    element.showBlueFocusBorder = true;
+    assert.equal(element.computeClass(), 'borderless showBlueFocusBorder');
   });
 
-  test('undefined or empty text results in no suggestions', () => {
-    element._updateSuggestions(undefined, 0, undefined);
-    assert.equal(element._suggestions.length, 0);
+  test('empty text results in no suggestions', async () => {
+    element.text = '';
+    element.threshold = 0;
+    element.noDebounce = false;
+    await element.updateComplete;
+    assert.equal(element.suggestions.length, 0);
   });
 
-  test('when focused', () => {
+  test('when focused', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
@@ -275,15 +302,16 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'bla';
-    assert.equal(element._focused, true);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
+    assert.equal(element.focused, true);
+    await element.updateComplete;
+    return promise.then(async () => {
+      await waitUntil(() => element.suggestions.length > 0);
+      assert.equal(element.suggestions.length, 1);
       assert.equal(queryStub.notCalled, false);
     });
   });
 
-  test('when not focused', () => {
+  test('when not focused', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
@@ -294,14 +322,14 @@
       );
     element.query = queryStub;
     element.text = 'bla';
-    assert.equal(element._focused, false);
-    flush();
+    assert.equal(element.focused, false);
+    await element.updateComplete;
     return promise.then(() => {
-      assert.equal(element._suggestions.length, 0);
+      assert.equal(element.suggestions.length, 0);
     });
   });
 
-  test('suggestions should not carry over', () => {
+  test('suggestions should not carry over', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
@@ -313,15 +341,19 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'bla';
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      element._updateSuggestions('', 0, false);
-      assert.equal(element._suggestions.length, 0);
+    await element.updateComplete;
+    return promise.then(async () => {
+      await waitUntil(() => element.suggestions.length > 0);
+      assert.equal(element.suggestions.length, 1);
+      element.text = '';
+      element.threshold = 0;
+      element.noDebounce = false;
+      await element.updateComplete;
+      assert.equal(element.suggestions.length, 0);
     });
   });
 
-  test('multi completes only the last part of the query', () => {
+  test('multi completes only the last part of the query', async () => {
     let promise;
     const queryStub = sinon
       .stub()
@@ -334,139 +366,160 @@
     focusOnInput();
     element.text = 'blah blah';
     element.multi = true;
+    await element.updateComplete;
 
-    return promise.then(() => {
+    return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
+      await waitUntil(() => element.suggestionsDropdown?.isHidden === false);
 
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
 
-      assert.isTrue(commitHandler.called);
+      await waitUntil(() => commitHandler.called);
       assert.equal(element.text, 'blah 0');
     });
   });
 
-  test('tabComplete flag functions', () => {
+  test('tabComplete flag functions', async () => {
     // commitHandler checks for the commit event, whereas commitSpy checks for
     // the _commit function of the element.
     const commitHandler = sinon.spy();
     element.addEventListener('commit', commitHandler);
     const commitSpy = sinon.spy(element, '_commit');
-    element._focused = true;
+    element.setFocus(true);
 
-    element._suggestions = [{text: 'tunnel snakes rule!'}];
+    element.suggestions = [{text: 'tunnel snakes rule!', name: ''}];
     element.tabComplete = false;
     MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    await element.updateComplete;
+
     assert.isFalse(commitHandler.called);
     assert.isFalse(commitSpy.called);
-    assert.isFalse(element._focused);
+    assert.isFalse(element.focused);
 
     element.tabComplete = true;
-    element._focused = true;
+    await element.updateComplete;
+    element.setFocus(true);
+    await element.updateComplete;
     MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+
+    await waitUntil(() => commitSpy.called);
     assert.isFalse(commitHandler.called);
-    assert.isTrue(commitSpy.called);
-    assert.isTrue(element._focused);
+    assert.isTrue(element.focused);
   });
 
-  test('_focused flag properly triggered', () => {
-    flush();
-    assert.isFalse(element._focused);
+  test('focused flag properly triggered', async () => {
+    await element.updateComplete;
+    assert.isFalse(element.focused);
     const input = queryAndAssert<PaperInputElement>(
       element,
       'paper-input'
     ).inputElement;
     MockInteractions.focus(input);
-    assert.isTrue(element._focused);
+    assert.isTrue(element.focused);
   });
 
-  test('search icon shows with showSearchIcon property', () => {
-    flush();
+  test('search icon shows with showSearchIcon property', async () => {
     assert.equal(
       getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
       'none'
     );
     element.showSearchIcon = true;
+    await element.updateComplete;
+
     assert.notEqual(
       getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
       'none'
     );
   });
 
-  test('vertical offset overridden by param if it exists', () => {
+  test('vertical offset overridden by param if it exists', async () => {
     assert.equal(suggestionsEl().verticalOffset, 31);
+
     element.verticalOffset = 30;
+    await element.updateComplete;
+
     assert.equal(suggestionsEl().verticalOffset, 30);
   });
 
-  test('_focused flag shows/hides the suggestions', () => {
+  test('focused flag shows/hides the suggestions', async () => {
     const openStub = sinon.stub(suggestionsEl(), 'open');
     const closedStub = sinon.stub(suggestionsEl(), 'close');
-    element._suggestions = [{text: 'hello'}, {text: 'its me'}];
+    element.suggestions = [{text: 'hello'}, {text: 'its me'}];
     assert.isFalse(openStub.called);
-    assert.isTrue(closedStub.calledOnce);
-    element._focused = true;
-    assert.isTrue(openStub.calledOnce);
-    element._suggestions = [];
-    assert.isTrue(closedStub.calledTwice);
+    await waitUntil(() => closedStub.calledOnce);
+    element.setFocus(true);
+    await waitUntil(() => openStub.calledOnce);
+    element.suggestions = [];
+    await waitUntil(() => closedStub.calledTwice);
     assert.isTrue(openStub.calledOnce);
   });
 
   test(
-    '_handleInputCommit with autocomplete hidden does nothing without' +
+    'handleInputCommit with autocomplete hidden does nothing without' +
       'without allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       suggestionsEl().isHidden = true;
-      element._handleInputCommit();
+      element.handleInputCommit();
       assert.isFalse(commitStub.called);
     }
   );
 
   test(
-    '_handleInputCommit with autocomplete hidden with' +
+    'handleInputCommit with autocomplete hidden with' +
       'allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       element.allowNonSuggestedValues = true;
       suggestionsEl().isHidden = true;
-      element._handleInputCommit();
+      element.handleInputCommit();
       assert.isTrue(commitStub.called);
     }
   );
 
-  test('_handleInputCommit with autocomplete open calls commit', () => {
+  test('handleInputCommit with autocomplete open calls commit', () => {
     const commitStub = sinon.stub(element, '_commit');
     suggestionsEl().isHidden = false;
-    element._handleInputCommit();
+    element.handleInputCommit();
     assert.isTrue(commitStub.calledOnce);
   });
 
   test(
-    '_handleInputCommit with autocomplete open calls commit' +
+    'handleInputCommit with autocomplete open calls commit' +
       'with allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       element.allowNonSuggestedValues = true;
       suggestionsEl().isHidden = false;
-      element._handleInputCommit();
+      element.handleInputCommit();
       assert.isTrue(commitStub.calledOnce);
     }
   );
 
-  test('issue 8655', () => {
+  test('issue 8655', async () => {
     function makeSuggestion(s: string) {
       return {name: s, text: s, value: s};
     }
-    const keydownSpy = sinon.spy(element, '_handleKeydown');
+    const keydownSpy = sinon.spy(element, 'handleKeydown');
+    element.requestUpdate();
+    await element.updateComplete;
+
+    // const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
     element.setText('file:');
-    element._suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
+    element.suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
+    await element.updateComplete;
+
     MockInteractions.pressAndReleaseKeyOn(inputEl(), 88, null, 'x');
     // Must set the value, because the MockInteraction does not.
     inputEl().value = 'file:x';
+
     assert.isTrue(keydownSpy.calledOnce);
+
     MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    await element.updateComplete;
     assert.isTrue(keydownSpy.calledTwice);
+
     assert.equal(element.text, 'file:x');
   });
 
@@ -478,50 +531,56 @@
       commitSpy = sinon.spy(element, '_commit');
     });
 
-    test('enter does not call focus', () => {
-      element._suggestions = [{text: 'sugar bombs'}];
+    test('enter does not call focus', async () => {
+      element.suggestions = [{text: 'sugar bombs'}];
+
       focusSpy = sinon.spy(element, 'focus');
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
-      flush();
 
-      assert.isTrue(commitSpy.called);
+      // Dropdown is hidden without focus so this should never happen?
+      await waitUntil(() => commitSpy.called);
+
       assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 0);
+      assert.equal(element.suggestions.length, 0);
     });
 
-    test('tab in input, tabComplete = true', () => {
+    test('tab in input, tabComplete = true', async () => {
       focusSpy = sinon.spy(element, 'focus');
       const commitHandler = sinon.stub();
       element.addEventListener('commit', commitHandler);
       element.tabComplete = true;
-      element._suggestions = [{text: 'tunnel snakes drool'}];
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
-      flush();
+      element.suggestions = [{text: 'tunnel snakes drool'}];
 
-      assert.isTrue(commitSpy.called);
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+
+      await waitUntil(() => commitSpy.called);
+
       assert.isTrue(focusSpy.called);
       assert.isFalse(commitHandler.called);
-      assert.equal(element._suggestions.length, 0);
+      assert.equal(element.suggestions.length, 0);
     });
 
-    test('tab in input, tabComplete = false', () => {
-      element._suggestions = [{text: 'sugar bombs'}];
+    test('tab in input, tabComplete = false', async () => {
+      element.suggestions = [{text: 'sugar bombs'}];
       focusSpy = sinon.spy(element, 'focus');
       MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
-      flush();
+      await element.updateComplete;
 
       assert.isFalse(commitSpy.called);
       assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 1);
+      await waitUntil(() => element.suggestions.length > 0);
+      assert.equal(element.suggestions.length, 1);
     });
 
     test('tab on suggestion, tabComplete = false', async () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
+      element.suggestions = [{name: 'sugar bombs'}];
+      element.setFocus(true);
       // When tabComplete is false, do not focus.
       element.tabComplete = false;
       focusSpy = sinon.spy(element, 'focus');
-      flush$0();
+
+      await element.updateComplete;
+
       assert.isFalse(suggestionsEl().isHidden);
 
       MockInteractions.pressAndReleaseKeyOn(
@@ -530,18 +589,20 @@
         null,
         'Tab'
       );
-      await flush();
+      await element.updateComplete;
       assert.isFalse(commitSpy.called);
-      assert.isFalse(element._focused);
+      assert.isFalse(element.focused);
     });
 
     test('tab on suggestion, tabComplete = true', async () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
+      element.suggestions = [{name: 'sugar bombs'}];
+      element.setFocus(true);
       // When tabComplete is true, focus.
       element.tabComplete = true;
       focusSpy = sinon.spy(element, 'focus');
-      flush$0();
+
+      await element.updateComplete;
+
       assert.isFalse(suggestionsEl().isHidden);
 
       MockInteractions.pressAndReleaseKeyOn(
@@ -550,42 +611,52 @@
         null,
         'Tab'
       );
-      await flush();
+      await element.updateComplete;
 
       assert.isTrue(commitSpy.called);
-      assert.isTrue(element._focused);
+      assert.isTrue(element.focused);
     });
 
-    test('tap on suggestion commits, does not call focus', () => {
+    test('tap on suggestion commits, does not call focus', async () => {
       focusSpy = sinon.spy(element, 'focus');
-      element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      flush$0();
-      assert.isFalse(suggestionsEl().isHidden);
-      MockInteractions.tap(queryAndAssert(suggestionsEl(), 'li:first-child'));
-      flush();
+      element.setFocus(true);
+      element.suggestions = [{name: 'first suggestion'}];
 
+      await element.updateComplete;
+
+      await waitUntil(() => !suggestionsEl().isHidden);
+      MockInteractions.tap(queryAndAssert(suggestionsEl(), 'li:first-child'));
+
+      await waitUntil(() => suggestionsEl().isHidden);
       assert.isFalse(focusSpy.called);
       assert.isTrue(commitSpy.called);
-      assert.isTrue(suggestionsEl().isHidden);
     });
   });
 
-  test('input-keydown event fired', () => {
+  test('input-keydown event fired', async () => {
     const listener = sinon.spy();
     element.addEventListener('input-keydown', listener);
     MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
-    flush();
+    await element.updateComplete;
     assert.isTrue(listener.called);
   });
 
-  test('enter with modifier does not complete', () => {
-    const handleSpy = sinon.spy(element, '_handleKeydown');
-    const commitStub = sinon.stub(element, '_handleInputCommit');
+  test('enter with modifier does not complete', async () => {
+    const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+    const commitStub = sinon.stub(element, 'handleInputCommit');
     MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, 'ctrl', 'enter');
-    assert.isTrue(handleSpy.called);
+    await element.updateComplete;
+
+    assert.equal(dispatchEventStub.lastCall.args[0].type, 'input-keydown');
+    assert.equal(
+      (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.keyCode,
+      13
+    );
+
     assert.isFalse(commitStub.called);
     MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    await element.updateComplete;
+
     assert.isTrue(commitStub.called);
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 89bcbc7..3181445 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -22,7 +22,6 @@
 import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
 import {getAppContext} from '../../../services/app-context';
 import {classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -31,6 +30,7 @@
 }
 /**
  * @attr {Boolean} no-uppercase - text in button is not uppercased
+ * @attr {Boolean} position-below
  * @attr {Boolean} primary - set primary button color
  * @attr {Boolean} secondary - set secondary button color
  */
@@ -160,6 +160,10 @@
           cursor: default;
         }
 
+        :host([disabled][flatten]) {
+          --background-color: transparent;
+        }
+
         /* Styles for link buttons specifically */
         :host([link]) {
           --background-color: transparent;
@@ -201,23 +205,16 @@
     ];
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   override render() {
     return html`<paper-button
-      ?raised="${!this.link && !this.flatten}"
-      ?disabled="${this.disabled || this.loading}"
+      ?raised=${!this.link && !this.flatten}
+      ?disabled=${this.disabled || this.loading}
       role="button"
       tabindex="-1"
       part="paper-button"
-      class="${classMap({
-        voteChip: this.voteChip && !this.isSubmitRequirementsUiEnabled,
-        newVoteChip: this.voteChip && this.isSubmitRequirementsUiEnabled,
-      })}"
+      class=${classMap({
+        newVoteChip: this.voteChip,
+      })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
       <slot></slot>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 172518b..dd6077c 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -17,15 +17,15 @@
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-status_html';
-import {customElement, property} from '@polymer/decorators';
 import {
   GeneratedWebLink,
   GerritNav,
 } from '../../core/gr-navigation/gr-navigation';
 import {ChangeInfo} from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 export enum ChangeStates {
   ABANDONED = 'Abandoned',
@@ -54,18 +54,14 @@
   'current reviewers (or anyone with "View Private Changes" permission).';
 
 @customElement('gr-change-status')
-export class GrChangeStatus extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean, reflectToAttribute: true})
+export class GrChangeStatus extends LitElement {
+  @property({type: Boolean, reflect: true})
   flat = false;
 
   @property({type: Object})
   change?: ChangeInfo | ParsedChangeInfo;
 
-  @property({type: String, observer: '_updateChipDetails'})
+  @property({type: String})
   status?: ChangeStates;
 
   @property({type: String})
@@ -77,60 +73,170 @@
   @property({type: Object})
   resolveWeblinks?: GeneratedWebLink[] = [];
 
-  _computeStatusString(status?: ChangeStates) {
-    if (status === ChangeStates.WIP && !this.flat) {
-      return 'Work in Progress';
-    }
-    return status ?? '';
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .chip {
+          border-radius: var(--border-radius);
+          background-color: var(--chip-background-color);
+          padding: 0 var(--spacing-m);
+          white-space: nowrap;
+        }
+        :host(.merged) .chip {
+          background-color: var(--status-merged);
+          color: var(--status-merged);
+        }
+        :host(.abandoned) .chip {
+          background-color: var(--status-abandoned);
+          color: var(--status-abandoned);
+        }
+        :host(.wip) .chip {
+          background-color: var(--status-wip);
+          color: var(--status-wip);
+        }
+        :host(.private) .chip {
+          background-color: var(--status-private);
+          color: var(--status-private);
+        }
+        :host(.merge-conflict) .chip {
+          background-color: var(--status-conflict);
+          color: var(--status-conflict);
+        }
+        :host(.active) .chip {
+          background-color: var(--status-active);
+          color: var(--status-active);
+        }
+        :host(.ready-to-submit) .chip {
+          background-color: var(--status-ready);
+          color: var(--status-ready);
+        }
+        :host(.revert-created) .chip {
+          background-color: var(--status-revert-created);
+          color: var(--status-revert-created);
+        }
+        :host(.revert-submitted) .chip {
+          background-color: var(--status-revert-created);
+          color: var(--status-revert-created);
+        }
+        .status-link {
+          text-decoration: none;
+        }
+        :host(.custom) .chip {
+          background-color: var(--status-custom);
+          color: var(--status-custom);
+        }
+        :host([flat]) .chip {
+          background-color: transparent;
+          padding: 0;
+        }
+        :host(:not([flat])) .chip,
+        .icon {
+          color: var(--status-text-color);
+        }
+        .icon {
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+      `,
+    ];
   }
 
-  _toClassName(str?: ChangeStates) {
+  override render() {
+    return html`
+      <gr-tooltip-content
+        has-tooltip
+        position-below
+        .title=${this.tooltipText}
+        .maxWidth=${'40em'}
+      >
+        ${this.renderStatusLink()}
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderStatusLink() {
+    if (!this.hasStatusLink()) {
+      return html`
+        <div class="chip" aria-label="Label: ${this.status}">
+          ${this.computeStatusString()}
+        </div>
+      `;
+    }
+
+    return html`
+      <a class="status-link" href=${this.getStatusLink()}>
+        <div class="chip" aria-label="Label: ${this.status}">
+          ${this.computeStatusString()}
+          ${this.showResolveIcon()
+            ? html`<iron-icon class="icon" icon="gr-icons:edit"></iron-icon>`
+            : ''}
+        </div>
+      </a>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('status')) {
+      this.updateChipDetails(changedProperties.get('status') as ChangeStates);
+    }
+  }
+
+  private computeStatusString() {
+    if (this.status === ChangeStates.WIP && !this.flat) {
+      return 'Work in Progress';
+    }
+    return this.status ?? '';
+  }
+
+  private toClassName(str?: ChangeStates) {
     return str ? str.toLowerCase().replace(/\s/g, '-') : '';
   }
 
-  hasStatusLink(
-    revertedChange?: ChangeInfo,
-    resolveWeblinks?: GeneratedWebLink[],
-    status?: ChangeStates
-  ): boolean {
+  // private but used in test
+  hasStatusLink(): boolean {
     const isRevertCreatedOrSubmitted =
-      (status === ChangeStates.REVERT_SUBMITTED ||
-        status === ChangeStates.REVERT_CREATED) &&
-      revertedChange !== undefined;
+      (this.status === ChangeStates.REVERT_SUBMITTED ||
+        this.status === ChangeStates.REVERT_CREATED) &&
+      this.revertedChange !== undefined;
     return (
       isRevertCreatedOrSubmitted ||
-      !!(status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length)
+      !!(
+        this.status === ChangeStates.MERGE_CONFLICT &&
+        this.resolveWeblinks?.length
+      )
     );
   }
 
-  getStatusLink(
-    revertedChange?: ChangeInfo,
-    resolveWeblinks?: GeneratedWebLink[],
-    status?: ChangeStates
-  ): string {
-    if (revertedChange) {
-      return GerritNav.getUrlForSearchQuery(`${revertedChange._number}`);
+  // private but used in test
+  getStatusLink(): string {
+    if (this.revertedChange) {
+      return GerritNav.getUrlForSearchQuery(`${this.revertedChange._number}`);
     }
-    if (status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length) {
-      return resolveWeblinks[0].url ?? '';
+    if (
+      this.status === ChangeStates.MERGE_CONFLICT &&
+      this.resolveWeblinks?.length
+    ) {
+      return this.resolveWeblinks[0].url ?? '';
     }
     return '';
   }
 
-  showResolveIcon(
-    resolveWeblinks?: GeneratedWebLink[],
-    status?: ChangeStates
-  ): boolean {
-    return status === ChangeStates.MERGE_CONFLICT && !!resolveWeblinks?.length;
+  // private but used in test
+  showResolveIcon(): boolean {
+    return (
+      this.status === ChangeStates.MERGE_CONFLICT &&
+      !!this.resolveWeblinks?.length
+    );
   }
 
-  _updateChipDetails(status?: ChangeStates, previousStatus?: ChangeStates) {
+  private updateChipDetails(previousStatus?: ChangeStates) {
     if (previousStatus) {
-      this.classList.remove(this._toClassName(previousStatus));
+      this.classList.remove(this.toClassName(previousStatus));
     }
-    this.classList.add(this._toClassName(status));
+    this.classList.add(this.toClassName(this.status));
 
-    switch (status) {
+    switch (this.status) {
       case ChangeStates.WIP:
         this.tooltipText = WIP_TOOLTIP;
         break;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
deleted file mode 100644
index 455bd4e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .chip {
-      border-radius: var(--border-radius);
-      background-color: var(--chip-background-color);
-      padding: 0 var(--spacing-m);
-      white-space: nowrap;
-    }
-    :host(.merged) .chip {
-      background-color: var(--status-merged);
-      color: var(--status-merged);
-    }
-    :host(.abandoned) .chip {
-      background-color: var(--status-abandoned);
-      color: var(--status-abandoned);
-    }
-    :host(.wip) .chip {
-      background-color: var(--status-wip);
-      color: var(--status-wip);
-    }
-    :host(.private) .chip {
-      background-color: var(--status-private);
-      color: var(--status-private);
-    }
-    :host(.merge-conflict) .chip {
-      background-color: var(--status-conflict);
-      color: var(--status-conflict);
-    }
-    :host(.active) .chip {
-      background-color: var(--status-active);
-      color: var(--status-active);
-    }
-    :host(.ready-to-submit) .chip {
-      background-color: var(--status-ready);
-      color: var(--status-ready);
-    }
-    :host(.revert-created) .chip {
-      background-color: var(--status-revert-created);
-      color: var(--status-revert-created);
-    }
-    :host(.revert-submitted) .chip {
-      background-color: var(--status-revert-created);
-      color: var(--status-revert-created);
-    }
-    .status-link {
-      text-decoration: none;
-    }
-    :host(.custom) .chip {
-      background-color: var(--status-custom);
-      color: var(--status-custom);
-    }
-    :host([flat]) .chip {
-      background-color: transparent;
-      padding: 0;
-    }
-    :host(:not([flat])) .chip, .icon {
-      color: var(--status-text-color);
-    }
-    .icon {
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-  </style>
-  <gr-tooltip-content
-    has-tooltip
-    position-below
-    title="[[tooltipText]]"
-    max-width="40em"
-  >
-    <template
-      is="dom-if"
-      if="[[hasStatusLink(revertedChange, resolveWeblinks, status)]]">
-      <a class="status-link"
-         href="[[getStatusLink(revertedChange, resolveWeblinks, status)]]">
-        <div class="chip" aria-label$="Label: [[status]]">
-          [[_computeStatusString(status)]]
-          <iron-icon
-            class="icon"
-            icon="gr-icons:edit"
-            hidden$="[[!showResolveIcon(resolveWeblinks, status)]]">
-          </iron-icon>
-        </div>
-      </a>
-    </template>
-    <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
-      <div class="chip" aria-label$="Label: [[status]]"
-      >[[_computeStatusString(status)]]</div>
-    </template>
-  </gr-tooltip-content>
-</span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
index f6e19aa..654c574 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -21,8 +21,8 @@
 import {ChangeStates, GrChangeStatus, WIP_TOOLTIP} from './gr-change-status';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status';
-
-const basicFixture = fixtureFromElement('gr-change-status');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const PRIVATE_TOOLTIP =
   'This change is only visible to its owner and ' +
@@ -31,27 +31,29 @@
 suite('gr-change-status tests', () => {
   let element: GrChangeStatus;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrChangeStatus>(html`
+      <gr-change-status></gr-change-status>
+    `);
   });
 
-  test('WIP', () => {
+  test('WIP', async () => {
     element.status = ChangeStates.WIP;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Work in Progress'
     );
     assert.equal(element.tooltipText, WIP_TOOLTIP);
     assert.isTrue(element.classList.contains('wip'));
   });
 
-  test('WIP flat', () => {
+  test('WIP flat', async () => {
     element.flat = true;
     element.status = ChangeStates.WIP;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'WIP'
     );
     assert.isDefined(element.tooltipText);
@@ -59,44 +61,47 @@
     assert.isTrue(element.hasAttribute('flat'));
   });
 
-  test('merged', () => {
+  test('merged', async () => {
     element.status = ChangeStates.MERGED;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Merged'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('merged'));
-    assert.isFalse(
-      element.showResolveIcon([{url: 'http://google.com'}], ChangeStates.MERGED)
-    );
+    element.resolveWeblinks = [{url: 'http://google.com'}];
+    element.status = ChangeStates.MERGED;
+    assert.isFalse(element.showResolveIcon());
   });
 
-  test('abandoned', () => {
+  test('abandoned', async () => {
     element.status = ChangeStates.ABANDONED;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Abandoned'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('abandoned'));
   });
 
-  test('merge conflict', () => {
+  test('merge conflict', async () => {
     const status = ChangeStates.MERGE_CONFLICT;
     element.status = status;
-    flush();
+    await element.updateComplete;
 
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Merge Conflict'
     );
     assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
     assert.isTrue(element.classList.contains('merge-conflict'));
-    assert.isFalse(element.hasStatusLink(undefined, [], status));
-    assert.isFalse(element.showResolveIcon([], status));
+    element.revertedChange = undefined;
+    element.resolveWeblinks = [];
+    element.status = status;
+    assert.isFalse(element.hasStatusLink());
+    assert.isFalse(element.showResolveIcon());
   });
 
   test('merge conflict with resolve link', () => {
@@ -104,9 +109,12 @@
     const url = 'http://google.com';
     const weblinks = [{url}];
 
-    assert.isTrue(element.hasStatusLink(undefined, weblinks, status));
-    assert.equal(element.getStatusLink(undefined, weblinks, status), url);
-    assert.isTrue(element.showResolveIcon(weblinks, status));
+    element.revertedChange = undefined;
+    element.resolveWeblinks = weblinks;
+    element.status = status;
+    assert.isTrue(element.hasStatusLink());
+    assert.equal(element.getStatusLink(), url);
+    assert.isTrue(element.showResolveIcon());
   });
 
   test('reverted change', () => {
@@ -115,51 +123,54 @@
     const revertedChange = createChange();
     sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
 
-    assert.isTrue(element.hasStatusLink(revertedChange, [], status));
-    assert.equal(element.getStatusLink(revertedChange, [], status), url);
+    element.revertedChange = revertedChange;
+    element.resolveWeblinks = [];
+    element.status = status;
+    assert.isTrue(element.hasStatusLink());
+    assert.equal(element.getStatusLink(), url);
   });
 
-  test('private', () => {
+  test('private', async () => {
     element.status = ChangeStates.PRIVATE;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Private'
     );
     assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
     assert.isTrue(element.classList.contains('private'));
   });
 
-  test('active', () => {
+  test('active', async () => {
     element.status = ChangeStates.ACTIVE;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Active'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('active'));
   });
 
-  test('ready to submit', () => {
+  test('ready to submit', async () => {
     element.status = ChangeStates.READY_TO_SUBMIT;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Ready to submit'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('ready-to-submit'));
   });
 
-  test('updating status removes the previous class', () => {
+  test('updating status removes the previous class', async () => {
     element.status = ChangeStates.PRIVATE;
-    flush();
+    await element.updateComplete;
     assert.isTrue(element.classList.contains('private'));
     assert.isFalse(element.classList.contains('wip'));
 
     element.status = ChangeStates.WIP;
-    flush();
+    await element.updateComplete;
     assert.isFalse(element.classList.contains('private'));
     assert.isTrue(element.classList.contains('wip'));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index ecb5761..f5f9584 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -56,8 +56,8 @@
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
 import {assertIsDefined} from '../../../utils/common-util';
-import {fire, fireAlert, waitForEventOnce} from '../../../utils/event-util';
-import {GrSyntaxLayer} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer';
+import {fire, fireAlert} from '../../../utils/event-util';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
 import {getUserName} from '../../../utils/display-name-util';
@@ -258,41 +258,55 @@
 
   private readonly shortcuts = new ShortcutController(this);
 
-  private readonly syntaxLayer = new GrSyntaxLayer();
+  private readonly syntaxLayer = new GrSyntaxLayerWorker();
 
   constructor() {
     super();
     this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
     this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
-  }
-
-  override connectedCallback(): void {
-    super.connectedCallback();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
-    subscribe(this, this.userModel.diffPreferences$, x =>
-      this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
     );
-    subscribe(this, this.userModel.preferences$, prefs => {
-      const layers: DiffLayer[] = [this.syntaxLayer];
-      if (!prefs.disable_token_highlighting) {
-        layers.push(new TokenHighlightLayer(this));
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
+        if (!prefs.disable_token_highlighting) {
+          layers.push(new TokenHighlightLayer(this));
+        }
+        this.layers = layers;
       }
-      this.layers = layers;
-    });
-    subscribe(this, this.userModel.diffPreferences$, prefs => {
-      this.prefs = {
-        ...prefs,
-        // set line_wrapping to true so that the context can take all the
-        // remaining space after comment card has rendered
-        line_wrapping: true,
-      };
-    });
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      prefs => {
+        this.prefs = {
+          ...prefs,
+          // set line_wrapping to true so that the context can take all the
+          // remaining space after comment card has rendered
+          line_wrapping: true,
+        };
+      }
+    );
   }
 
   static override get styles() {
@@ -438,9 +452,7 @@
     return html`
       ${this.renderFileName()}
       <div class="pathInfo">
-        ${href
-          ? html`<a href="${href}">${line}</a>`
-          : html`<span>${line}</span>`}
+        ${href ? html`<a href=${href}>${line}</a>` : html`<span>${line}</span>`}
       </div>
     `;
   }
@@ -455,9 +467,9 @@
     return html`
       <div class="fileName">
         ${href
-          ? html`<a href="${href}">${displayPath}</a>`
+          ? html`<a href=${href}>${displayPath}</a>`
           : html`<span>${displayPath}</span>`}
-        <gr-copy-clipboard hideInput .text="${displayPath}"></gr-copy-clipboard>
+        <gr-copy-clipboard hideInput .text=${displayPath}></gr-copy-clipboard>
       </div>
     `;
   }
@@ -481,21 +493,21 @@
             : !this.unresolved);
         return html`
           <gr-comment
-            .comment="${comment}"
-            .comments="${this.thread!.comments}"
-            ?initially-collapsed="${initiallyCollapsed}"
-            ?robot-button-disabled="${robotButtonDisabled}"
-            ?show-patchset="${this.showPatchset}"
-            ?show-ported-comment="${this.showPortedComment &&
-            comment.id === this.rootId}"
-            @create-fix-comment="${this.handleCommentFix}"
-            @copy-comment-link="${this.handleCopyLink}"
-            @comment-editing-changed="${(e: CustomEvent) => {
+            .comment=${comment}
+            .comments=${this.thread!.comments}
+            ?initially-collapsed=${initiallyCollapsed}
+            ?robot-button-disabled=${robotButtonDisabled}
+            ?show-patchset=${this.showPatchset}
+            ?show-ported-comment=${this.showPortedComment &&
+            comment.id === this.rootId}
+            @create-fix-comment=${this.handleCommentFix}
+            @copy-comment-link=${this.handleCopyLink}
+            @comment-editing-changed=${(e: CustomEvent) => {
               if (isDraftOrUnsaved(comment)) this.editing = e.detail;
-            }}"
-            @comment-unresolved-changed="${(e: CustomEvent) => {
+            }}
+            @comment-unresolved-changed=${(e: CustomEvent) => {
               if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
-            }}"
+            }}
           ></gr-comment>
         `;
       }
@@ -513,7 +525,7 @@
         <div id="actions">
           <iron-icon
               class="link-icon copy"
-              @click="${this.handleCopyLink}"
+              @click=${this.handleCopyLink}
               title="Copy link to this comment"
               icon="gr-icons:link"
               role="button"
@@ -524,16 +536,16 @@
               id="replyBtn"
               link
               class="action reply"
-              ?disabled="${this.saving}"
-              @click="${() => this.handleCommentReply(false)}"
+              ?disabled=${this.saving}
+              @click=${() => this.handleCommentReply(false)}
           >Reply</gr-button
           >
           <gr-button
               id="quoteBtn"
               link
               class="action quote"
-              ?disabled="${this.saving}"
-              @click="${() => this.handleCommentReply(true)}"
+              ?disabled=${this.saving}
+              @click=${() => this.handleCommentReply(true)}
           >Quote</gr-button
           >
           ${
@@ -543,16 +555,16 @@
                     id="ackBtn"
                     link
                     class="action ack"
-                    ?disabled="${this.saving}"
-                    @click="${this.handleCommentAck}"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentAck}
                     >Ack</gr-button
                   >
                   <gr-button
                     id="doneBtn"
                     link
                     class="action done"
-                    ?disabled="${this.saving}"
-                    @click="${this.handleCommentDone}"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentDone}
                     >Done</gr-button
                   >
                 `
@@ -572,17 +584,16 @@
       <div class="diff-container">
         <gr-diff
           id="diff"
-          .changeNum="${this.changeNum}"
-          .diff="${this.diff}"
-          .layers="${this.layers}"
-          .path="${this.thread.path}"
-          .prefs="${this.prefs}"
-          .renderPrefs="${this.renderPrefs}"
-          .highlightRange="${this.highlightRange}"
+          .diff=${this.diff}
+          .layers=${this.layers}
+          .path=${this.thread.path}
+          .prefs=${this.prefs}
+          .renderPrefs=${this.renderPrefs}
+          .highlightRange=${this.highlightRange}
         >
         </gr-diff>
         <div class="view-diff-container">
-          <a href="${href}">
+          <a href=${href}>
             <gr-button link class="view-diff-button">View Diff</gr-button>
           </a>
         </div>
@@ -680,10 +691,7 @@
     }
 
     if (!anyLineTooLong(diff)) {
-      this.syntaxLayer.init(diff);
-      waitForEventOnce(this, 'render').then(() => {
-        this.syntaxLayer.process();
-      });
+      this.syntaxLayer.process(diff);
     }
     return diff;
   }
@@ -735,11 +743,7 @@
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.repoName, 'repoName');
     const url = generateAbsoluteUrl(
-      GerritNav.getUrlForCommentsTab(
-        this.changeNum!,
-        this.repoName!,
-        comment.id
-      )
+      GerritNav.getUrlForCommentsTab(this.changeNum, this.repoName, comment.id)
     );
     assertIsDefined(url, 'url for comment');
     navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index a0fcc42..3e4a555 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -248,27 +248,36 @@
         });
       }
     }
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
     subscribe(
       this,
-      this.configModel().repoCommentLinks$,
+      () => this.configModel().repoCommentLinks$,
       x => (this.commentLinks = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
-
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.isAdmin$,
+      x => (this.isAdmin = x)
+    );
+
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
     subscribe(
       this,
-      this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+      () =>
+        this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
       () => {
         this.autoSave();
       }
@@ -454,11 +463,11 @@
     if (isUnsaved(this.comment) && !this.editing) return;
     const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
     return html`
-      <div id="container" class="${classMap(classes)}">
+      <div id="container" class=${classMap(classes)}>
         <div
           class="header"
           id="header"
-          @click="${() => (this.collapsed = !this.collapsed)}"
+          @click=${() => (this.collapsed = !this.collapsed)}
         >
           <div class="headerLeft">
             ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
@@ -486,8 +495,8 @@
     const classes = {draft: isDraftOrUnsaved(this.comment)};
     return html`
       <gr-account-label
-        .account="${this.comment?.author ?? this.account}"
-        class="${classMap(classes)}"
+        .account=${this.comment?.author ?? this.account}
+        class=${classMap(classes)}
       >
       </gr-account-label>
     `;
@@ -497,8 +506,8 @@
     if (!this.showPortedComment) return;
     if (!this.comment?.patch_set) return;
     return html`
-      <a href="${this.getUrlForComment()}">
-        <span class="portedMessage" @click="${this.handlePortedMessageClick}">
+      <a href=${this.getUrlForComment()}>
+        <span class="portedMessage" @click=${this.handlePortedMessageClick}>
           From patchset ${this.comment?.patch_set}
         </span>
       </a>
@@ -520,7 +529,7 @@
       <gr-tooltip-content
         class="draftTooltip"
         has-tooltip
-        title="${tooltip}"
+        title=${tooltip}
         max-width="20em"
         show-icon
       >
@@ -542,7 +551,7 @@
     return html`
       <div class="runIdMessage message">
         <div class="runIdInformation">
-          <a class="robotRunLink" href="${this.comment.url}">
+          <a class="robotRunLink" href=${this.comment.url}>
             <span class="robotRun link">Run Details</span>
           </a>
         </div>
@@ -568,7 +577,7 @@
         title="Delete Comment"
         link
         class="action delete"
-        @click="${this.openDeleteCommentOverlay}"
+        @click=${this.openDeleteCommentOverlay}
       >
         <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
       </gr-button>
@@ -587,10 +596,10 @@
     if (!this.comment?.updated || this.collapsed) return;
     return html`
       <span class="separator"></span>
-      <span class="date" tabindex="0" @click="${this.handleAnchorClick}">
+      <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
         <gr-date-formatter
           withTooltip
-          .dateStr="${this.comment.updated}"
+          .dateStr=${this.comment.updated}
         ></gr-date-formatter>
       </span>
     `;
@@ -603,14 +612,14 @@
     const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
     return html`
       <div class="show-hide" tabindex="0">
-        <label class="show-hide" aria-label="${ariaLabel}">
+        <label class="show-hide" aria-label=${ariaLabel}>
           <input
             type="checkbox"
             class="show-hide"
-            ?checked="${this.collapsed}"
-            @change="${() => (this.collapsed = !this.collapsed)}"
+            ?checked=${this.collapsed}
+            @change=${() => (this.collapsed = !this.collapsed)}
           />
-          <iron-icon id="icon" icon="${icon}"></iron-icon>
+          <iron-icon id="icon" icon=${icon}></iron-icon>
         </label>
       </div>
     `;
@@ -629,17 +638,17 @@
         class="editMessage"
         autocomplete="on"
         code=""
-        ?disabled="${this.saving}"
+        ?disabled=${this.saving}
         rows="4"
-        text="${this.messageText}"
-        @text-changed="${(e: ValueChangedEvent) => {
+        text=${this.messageText}
+        @text-changed=${(e: ValueChangedEvent) => {
           // TODO: This is causing a re-render of <gr-comment> on every key
           // press. Try to avoid always setting `this.messageText` or at least
           // debounce it. Most of the code can just inspect the current value
           // of the textare instead of needing a dedicated property.
           this.messageText = e.detail.value;
           this.autoSaveTrigger$.next();
-        }}"
+        }}
       ></gr-textarea>
     `;
   }
@@ -651,9 +660,9 @@
           gr-diff-selection.-->
       <gr-formatted-text
         class="message"
-        .content="${this.comment?.message}"
-        .config="${this.commentLinks}"
-        ?noTrailingMargin="${!isDraftOrUnsaved(this.comment)}"
+        .content=${this.comment?.message}
+        .config=${this.commentLinks}
+        ?noTrailingMargin=${!isDraftOrUnsaved(this.comment)}
       ></gr-formatted-text>
     `;
   }
@@ -664,7 +673,7 @@
     return html`
       <iron-icon
         class="copy link-icon"
-        @click="${this.handleCopyLink}"
+        @click=${this.handleCopyLink}
         title="Copy link to this comment"
         icon="gr-icons:link"
         role="button"
@@ -684,8 +693,8 @@
             <input
               type="checkbox"
               id="resolvedCheckbox"
-              ?checked="${!this.unresolved}"
-              @change="${this.handleToggleResolved}"
+              ?checked=${!this.unresolved}
+              @change=${this.handleToggleResolved}
             />
             Resolved
           </label>
@@ -711,9 +720,9 @@
     if (this.editing) return;
     return html`<gr-button
       link
-      ?disabled="${this.saving}"
+      ?disabled=${this.saving}
       class="action discard"
-      @click="${this.discard}"
+      @click=${this.discard}
       >Discard</gr-button
     >`;
   }
@@ -722,9 +731,9 @@
     if (this.editing) return;
     return html`<gr-button
       link
-      ?disabled="${this.saving}"
+      ?disabled=${this.saving}
       class="action edit"
-      @click="${this.edit}"
+      @click=${this.edit}
       >Edit</gr-button
     >`;
   }
@@ -734,9 +743,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.saving}"
+        ?disabled=${this.saving}
         class="action cancel"
-        @click="${this.cancel}"
+        @click=${this.cancel}
         >Cancel</gr-button
       >
     `;
@@ -747,9 +756,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.isSaveDisabled()}"
+        ?disabled=${this.isSaveDisabled()}
         class="action save"
-        @click="${this.save}"
+        @click=${this.save}
         >Save</gr-button
       >
     `;
@@ -759,7 +768,7 @@
     if (!this.account || !isRobot(this.comment)) return;
     const endpoint = html`
       <gr-endpoint-decorator name="robot-comment-controls">
-        <gr-endpoint-param name="comment" .value="${this.comment}">
+        <gr-endpoint-param name="comment" .value=${this.comment}>
         </gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
@@ -778,8 +787,8 @@
         link
         secondary
         class="action show-fix"
-        ?disabled="${this.saving}"
-        @click="${this.handleShowFix}"
+        ?disabled=${this.saving}
+        @click=${this.handleShowFix}
       >
         Show Fix
       </gr-button>
@@ -791,9 +800,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.robotButtonDisabled}"
+        ?disabled=${this.robotButtonDisabled}
         class="action fix"
-        @click="${this.handleFix}"
+        @click=${this.handleFix}
       >
         Please Fix
       </gr-button>
@@ -806,8 +815,8 @@
       <gr-overlay id="confirmDeleteOverlay" with-backdrop>
         <gr-confirm-delete-comment-dialog
           id="confirmDeleteComment"
-          @confirm="${this.handleConfirmDeleteComment}"
-          @cancel="${this.closeDeleteCommentOverlay}"
+          @confirm=${this.handleConfirmDeleteComment}
+          @cancel=${this.closeDeleteCommentOverlay}
         >
         </gr-confirm-delete-comment-dialog>
       </gr-overlay>
@@ -819,7 +828,7 @@
     if (!comment || !this.changeNum || !this.repoName) return '';
     if (!comment.id) throw new Error('comment must have an id');
     return GerritNav.getUrlForComment(
-      this.changeNum as NumericChangeId,
+      this.changeNum,
       this.repoName,
       comment.id
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 16ab2a2..bda076d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -405,7 +405,7 @@
     // messageText was empty so overwrite the message now
     assert.equal(element.messageText, 'hello world');
 
-    element.comment!.message = 'new message';
+    element.comment.message = 'new message';
     await element.updateComplete;
     // messageText was already set so do not overwrite it
     assert.equal(element.messageText, 'hello world');
@@ -430,7 +430,7 @@
     // messageText was empty so overwrite the message now
     assert.equal(element.messageText, 'hello world');
 
-    element.comment!.message = 'new message';
+    element.comment.message = 'new message';
     await element.updateComplete;
     // messageText was already set so do not overwrite it
     assert.equal(element.messageText, 'hello world');
@@ -504,7 +504,7 @@
       element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
       await element.updateComplete;
-      pressKey(element.textarea!.$.textarea.textarea, 's', Modifier.CTRL_KEY);
+      pressKey(element.textarea!.textarea!.textarea, 's', Modifier.CTRL_KEY);
       assert.isTrue(spy.called);
     });
 
@@ -630,7 +630,7 @@
         'create-fix-comment'
       );
       element.comment = createRobotComment();
-      element.comments = [element.comment!];
+      element.comments = [element.comment];
       await element.updateComplete;
 
       tap(queryAndAssert(element, '.fix'));
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index 0a9b9c3..f7f960f 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -15,32 +15,21 @@
  * limitations under the License.
  */
 import '../gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html';
-import {property, customElement} from '@polymer/decorators';
+import {css, html, LitElement} from 'lit';
+import {property, query, customElement} from 'lit/decorators';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-confirm-delete-comment-dialog': GrConfirmDeleteCommentDialog;
   }
 }
-export interface GrConfirmDeleteCommentDialog {
-  $: {
-    messageInput: IronAutogrowTextareaElement;
-  };
-}
 
 @customElement('gr-confirm-delete-comment-dialog')
-export class GrConfirmDeleteCommentDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  static get is() {
-    return 'gr-confirm-delete-comment-dialog';
-  }
+export class GrConfirmDeleteCommentDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -53,14 +42,79 @@
    * @event cancel
    */
 
+  @query('#messageInput')
+  messageInput?: IronAutogrowTextareaElement;
+
   @property({type: String})
   message = '';
 
-  resetFocus() {
-    this.$.messageInput.textarea.focus();
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        p {
+          margin-bottom: var(--spacing-l);
+        }
+        label {
+          cursor: pointer;
+          display: block;
+          width: 100%;
+        }
+        iron-autogrow-textarea {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 73ch; /* Add a char to account for the border. */
+        }
+      `,
+    ];
   }
 
-  _handleConfirmTap(e: Event) {
+  override render() {
+    return html` <gr-dialog
+      confirm-label="Delete"
+      @confirm=${this.handleConfirmTap}
+      @cancel=${this.handleCancelTap}
+    >
+      <div class="header" slot="header">Delete Comment</div>
+      <div class="main" slot="main">
+        <p>
+          This is an admin function. Please only use in exceptional
+          circumstances.
+        </p>
+        <label for="messageInput">Enter comment delete reason</label>
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          placeholder="&lt;Insert reasoning here&gt;"
+          .bindValue=${this.message}
+          @bind-value-changed=${(e: BindValueChangeEvent) => {
+            this.message = e.detail.value;
+          }}
+        ></iron-autogrow-textarea>
+      </div>
+    </gr-dialog>`;
+  }
+
+  resetFocus() {
+    assertIsDefined(this.messageInput, 'messageInput');
+    this.messageInput.textarea.focus();
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -72,7 +126,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
deleted file mode 100644
index 6876c1a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    p {
-      margin-bottom: var(--spacing-l);
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Delete"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Delete Comment</div>
-    <div class="main" slot="main">
-      <p>
-        This is an admin function. Please only use in exceptional circumstances.
-      </p>
-      <label for="messageInput">Enter comment delete reason</label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        placeholder="<Insert reasoning here>"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 01422a9..46bb7d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -97,15 +97,15 @@
       <div class="text">
         <iron-input
           class="copyText"
-          @click="${this._handleInputClick}"
+          @click=${this._handleInputClick}
           .bindValue=${this.text ?? ''}
         >
           <input
             id="input"
             is="iron-input"
-            class="${classMap({hideInput: this.hideInput})}"
+            class=${classMap({hideInput: this.hideInput})}
             type="text"
-            @click="${this._handleInputClick}"
+            @click=${this._handleInputClick}
             readonly=""
             .value=${this.text ?? ''}
             part="text-container-style"
@@ -113,13 +113,13 @@
         </iron-input>
         <gr-tooltip-content
           ?has-tooltip=${this.hasTooltip}
-          title="${ifDefined(this.buttonTitle)}"
+          title=${ifDefined(this.buttonTitle)}
         >
           <gr-button
             id="copy-clipboard-button"
             link=""
             class="copyToClipboard"
-            @click="${this._copyToClipboard}"
+            @click=${this._copyToClipboard}
             aria-label="Click to copy to clipboard"
           >
             <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index ef62fe9..6c43c43 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-copy-clipboard';
 import {GrCopyClipboard} from './gr-copy-clipboard';
@@ -53,10 +52,10 @@
     const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    const inputElement = queryAndAssert(element, 'input') as HTMLInputElement;
+    const inputElement = queryAndAssert<HTMLInputElement>(element, 'input');
     MockInteractions.tap(inputElement);
     assert.equal(inputElement.selectionStart, 0);
-    assert.equal(inputElement.selectionEnd, element.text!.length! - 1);
+    assert.equal(inputElement.selectionEnd, element.text!.length - 1);
   });
 
   test('hideInput', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index 1b094c1..538be7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -20,6 +20,7 @@
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -51,9 +52,19 @@
   @property({type: String, attribute: 'cancel-label'})
   cancelLabel = 'Cancel';
 
+  @property({type: Boolean, attribute: 'loading'})
+  loading = false;
+
+  @property({type: String, attribute: 'loading-label'})
+  loadingLabel = 'Loading...';
+
+  // TODO: Add consistent naming after Lit conversion of the codebase
   @property({type: Boolean})
   disabled = false;
 
+  @property({type: Boolean})
+  disableCancel = false;
+
   @property({type: Boolean, attribute: 'confirm-on-enter'})
   confirmOnEnter = false;
 
@@ -101,15 +112,24 @@
         footer {
           display: flex;
           flex-shrink: 0;
-          justify-content: flex-end;
           padding-top: var(--spacing-xl);
         }
+        .flex-space {
+          flex-grow: 1;
+        }
         gr-button {
           margin-left: var(--spacing-l);
         }
         .hidden {
           display: none;
         }
+        .loadingSpin {
+          width: 18px;
+          height: 18px;
+        }
+        .loadingLabel {
+          color: var(--gray-700);
+        }
       `,
     ];
   }
@@ -130,10 +150,19 @@
           </div>
         </main>
         <footer>
+          ${when(
+            this.loading,
+            () => html`
+              <span class="loadingSpin"></span>
+              <span class="loadingLabel"> ${this.loadingLabel} </span>
+            `
+          )}
+          <div class="flex-space"></div>
           <gr-button
             id="cancel"
-            class="${this.cancelLabel.length ? '' : 'hidden'}"
+            class=${this.cancelLabel.length ? '' : 'hidden'}
             link
+            ?disabled=${this.disableCancel}
             @click=${(e: Event) => this.handleCancelTap(e)}
           >
             ${this.cancelLabel}
@@ -142,7 +171,7 @@
             id="confirm"
             link
             primary
-            @click=${(e: Event) => this._handleConfirm(e)}
+            @click=${this._handleConfirm}
             ?disabled=${this.disabled}
             title=${this.confirmTooltip ?? ''}
           >
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index 171fc6c..e4f33bc 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -20,17 +20,93 @@
 import './gr-dialog';
 import {GrDialog} from './gr-dialog';
 import {isHidden, queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-dialog tests', () => {
   let element: GrDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrDialog>(html` <gr-dialog></gr-dialog> `);
     await element.updateComplete;
   });
 
+  test('renders', async () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="container">
+      <header class="heading-3">
+        <slot name="header"> </slot>
+      </header>
+      <main>
+        <div class="overflow-container">
+          <slot name="main"> </slot>
+        </div>
+      </main>
+      <footer>
+        <div class="flex-space"></div>
+        <gr-button
+          aria-disabled="false"
+          id="cancel"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Cancel
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="confirm"
+          link=""
+          primary=""
+          role="button"
+          tabindex="0"
+          title=""
+        >
+          Confirm
+        </gr-button>
+      </footer>
+    </div> `);
+  });
+
+  test('renders with loading state', async () => {
+    element.loading = true;
+    element.loadingLabel = 'Loading!!';
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="container">
+      <header class="heading-3">
+        <slot name="header"> </slot>
+      </header>
+      <main>
+        <div class="overflow-container">
+          <slot name="main"> </slot>
+        </div>
+      </main>
+      <footer>
+        <span class="loadingSpin"> </span>
+        <span class="loadingLabel"> Loading!! </span>
+        <div class="flex-space"></div>
+        <gr-button
+          aria-disabled="false"
+          id="cancel"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Cancel
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="confirm"
+          link=""
+          primary=""
+          role="button"
+          tabindex="0"
+          title=""
+        >
+          Confirm
+        </gr-button>
+      </footer>
+    </div> `);
+  });
+
   test('events', () => {
     const confirm = sinon.stub();
     const cancel = sinon.stub();
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 23d9693..cb51337 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -14,160 +14,327 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Subscription} from 'rxjs';
+
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-preferences_html';
-import {customElement, property} from '@polymer/decorators';
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
-import {GrSelect} from '../gr-select/gr-select';
 import {getAppContext} from '../../../services/app-context';
-
-export interface GrDiffPreferences {
-  $: {
-    contextLineSelect: HTMLInputElement;
-    columnsInput: HTMLInputElement;
-    tabSizeInput: HTMLInputElement;
-    fontSizeInput: HTMLInputElement;
-    lineWrappingInput: HTMLInputElement;
-    showTabsInput: HTMLInputElement;
-    showTrailingWhitespaceInput: HTMLInputElement;
-    automaticReviewInput: HTMLInputElement;
-    syntaxHighlightInput: HTMLInputElement;
-    contextSelect: GrSelect;
-    ignoreWhiteSpace: HTMLInputElement;
-  };
-  save(): void;
-}
+import {subscribe} from '../../lit/subscription-controller';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {convertToString} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {GrSelect} from '../gr-select/gr-select';
 
 @customElement('gr-diff-preferences')
-export class GrDiffPreferences extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrDiffPreferences extends LitElement {
+  @query('#contextLineSelect') private contextLineSelect?: HTMLInputElement;
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
+  @query('#columnsInput') private columnsInput?: HTMLInputElement;
 
-  @property({type: Object})
-  diffPrefs?: DiffPreferencesInfo;
+  @query('#tabSizeInput') private tabSizeInput?: HTMLInputElement;
+
+  @query('#fontSizeInput') private fontSizeInput?: HTMLInputElement;
+
+  @query('#lineWrappingInput') private lineWrappingInput?: HTMLInputElement;
+
+  @query('#showTabsInput') private showTabsInput?: HTMLInputElement;
+
+  @query('#showTrailingWhitespaceInput')
+  private showTrailingWhitespaceInput?: HTMLInputElement;
+
+  @query('#automaticReviewInput')
+  private automaticReviewInput?: HTMLInputElement;
+
+  @query('#syntaxHighlightInput')
+  private syntaxHighlightInput?: HTMLInputElement;
+
+  @query('#ignoreWhiteSpace') private ignoreWhiteSpace?: HTMLInputElement;
+
+  // Used in gr-diff-preferences-dialog
+  @query('#contextSelect') contextSelect?: GrSelect;
+
+  @state() diffPrefs?: DiffPreferencesInfo;
+
+  @state() private originalDiffPrefs?: DiffPreferencesInfo;
 
   private readonly userModel = getAppContext().userModel;
 
-  private subscriptions: Subscription[] = [];
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.subscriptions.push(
-      this.userModel.diffPreferences$.subscribe(diffPreferences => {
-        this.diffPrefs = diffPreferences;
-      })
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.originalDiffPrefs = diffPreferences;
+        this.diffPrefs = {...diffPreferences};
+      }
     );
   }
 
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-    super.disconnectedCallback();
+  static override get styles() {
+    return [sharedStyles, formStyles];
   }
 
-  _handleDiffPrefsChanged() {
-    this.hasUnsavedChanges = true;
+  override render() {
+    return html`
+      <div id="diffPreferences" class="gr-form-styles">
+        <section>
+          <label for="contextLineSelect" class="title">Context</label>
+          <span class="value">
+            <gr-select
+              id="contextSelect"
+              .bindValue=${convertToString(this.diffPrefs?.context)}
+              @change=${this.handleDiffContextChanged}
+            >
+              <select id="contextLineSelect">
+                <option value="3">3 lines</option>
+                <option value="10">10 lines</option>
+                <option value="25">25 lines</option>
+                <option value="50">50 lines</option>
+                <option value="75">75 lines</option>
+                <option value="100">100 lines</option>
+                <option value="-1">Whole file</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <label for="lineWrappingInput" class="title">Fit to screen</label>
+          <span class="value">
+            <input
+              id="lineWrappingInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.line_wrapping}
+              @change=${this.handleLineWrappingTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="columnsInput" class="title">Diff width</label>
+          <span class="value">
+            <iron-input
+              .allowedPattern=${'[0-9]'}
+              .bindValue=${convertToString(this.diffPrefs?.line_length)}
+              @change=${this.handleDiffLineLengthChanged}
+            >
+              <input id="columnsInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label for="tabSizeInput" class="title">Tab width</label>
+          <span class="value">
+            <iron-input
+              .allowedPattern=${'[0-9]'}
+              .bindValue=${convertToString(this.diffPrefs?.tab_size)}
+              @change=${this.handleDiffTabSizeChanged}
+            >
+              <input id="tabSizeInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label for="fontSizeInput" class="title">Font size</label>
+          <span class="value">
+            <iron-input
+              .allowedPattern=${'[0-9]'}
+              .bindValue=${convertToString(this.diffPrefs?.font_size)}
+              @change=${this.handleDiffFontSizeChanged}
+            >
+              <input id="fontSizeInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label for="showTabsInput" class="title">Show tabs</label>
+          <span class="value">
+            <input
+              id="showTabsInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.show_tabs}
+              @change=${this.handleShowTabsTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="showTrailingWhitespaceInput" class="title"
+            >Show trailing whitespace</label
+          >
+          <span class="value">
+            <input
+              id="showTrailingWhitespaceInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.show_whitespace_errors}
+              @change=${this.handleShowTrailingWhitespaceTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="syntaxHighlightInput" class="title"
+            >Syntax highlighting</label
+          >
+          <span class="value">
+            <input
+              id="syntaxHighlightInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.syntax_highlighting}
+              @change=${this.handleSyntaxHighlightTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="automaticReviewInput" class="title"
+            >Automatically mark viewed files reviewed</label
+          >
+          <span class="value">
+            <input
+              id="automaticReviewInput"
+              type="checkbox"
+              ?checked=${!this.diffPrefs?.manual_review}
+              @change=${this.handleAutomaticReviewTap}
+            />
+          </span>
+        </section>
+        <section>
+          <div class="pref">
+            <label for="ignoreWhiteSpace" class="title"
+              >Ignore Whitespace</label
+            >
+            <span class="value">
+              <gr-select
+                .bindValue=${convertToString(this.diffPrefs?.ignore_whitespace)}
+                @change=${this.handleDiffIgnoreWhitespaceChanged}
+              >
+                <select id="ignoreWhiteSpace">
+                  <option value="IGNORE_NONE">None</option>
+                  <option value="IGNORE_TRAILING">Trailing</option>
+                  <option value="IGNORE_LEADING_AND_TRAILING">
+                    Leading &amp; trailing
+                  </option>
+                  <option value="IGNORE_ALL">All</option>
+                </select>
+              </gr-select>
+            </span>
+          </div>
+        </section>
+      </div>
+    `;
   }
 
-  _handleDiffContextChanged() {
-    this.set('diffPrefs.context', Number(this.$.contextLineSelect.value));
-    this._handleDiffPrefsChanged();
-  }
+  private readonly handleDiffContextChanged = () => {
+    this.diffPrefs!.context = Number(this.contextLineSelect!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
 
-  _handleLineWrappingTap() {
-    this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
-    this._handleDiffPrefsChanged();
-  }
+  private readonly handleLineWrappingTap = () => {
+    this.diffPrefs!.line_wrapping = this.lineWrappingInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
 
-  _handleDiffLineLengthChanged() {
-    this.set('diffPrefs.line_length', Number(this.$.columnsInput.value));
-    this._handleDiffPrefsChanged();
-  }
+  private readonly handleDiffLineLengthChanged = () => {
+    this.diffPrefs!.line_length = Number(this.columnsInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
 
-  _handleDiffTabSizeChanged() {
-    this.set('diffPrefs.tab_size', Number(this.$.tabSizeInput.value));
-    this._handleDiffPrefsChanged();
-  }
+  private readonly handleDiffTabSizeChanged = () => {
+    this.diffPrefs!.tab_size = Number(this.tabSizeInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
 
-  _handleDiffFontSizeChanged() {
-    this.set('diffPrefs.font_size', Number(this.$.fontSizeInput.value));
-    this._handleDiffPrefsChanged();
-  }
+  private readonly handleDiffFontSizeChanged = () => {
+    this.diffPrefs!.font_size = Number(this.fontSizeInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
 
-  _handleShowTabsTap() {
-    this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
-    this._handleDiffPrefsChanged();
-  }
+  private readonly handleShowTabsTap = () => {
+    this.diffPrefs!.show_tabs = this.showTabsInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
 
-  _handleShowTrailingWhitespaceTap() {
-    this.set(
-      'diffPrefs.show_whitespace_errors',
-      this.$.showTrailingWhitespaceInput.checked
+  // private but used in test
+  readonly handleShowTrailingWhitespaceTap = () => {
+    this.diffPrefs!.show_whitespace_errors =
+      this.showTrailingWhitespaceInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleSyntaxHighlightTap = () => {
+    this.diffPrefs!.syntax_highlighting = this.syntaxHighlightInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleAutomaticReviewTap = () => {
+    this.diffPrefs!.manual_review = !this.automaticReviewInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffIgnoreWhitespaceChanged = () => {
+    this.diffPrefs!.ignore_whitespace = this.ignoreWhiteSpace!
+      .value as IgnoreWhitespaceType;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  hasUnsavedChanges() {
+    // We have to wrap boolean values in Boolean() to ensure undefined values
+    // use false rather than undefined.
+    return (
+      this.originalDiffPrefs?.context !== this.diffPrefs?.context ||
+      Boolean(this.originalDiffPrefs?.line_wrapping) !==
+        Boolean(this.diffPrefs?.line_wrapping) ||
+      this.originalDiffPrefs?.line_length !== this.diffPrefs?.line_length ||
+      this.originalDiffPrefs?.tab_size !== this.diffPrefs?.tab_size ||
+      this.originalDiffPrefs?.font_size !== this.diffPrefs?.font_size ||
+      this.originalDiffPrefs?.ignore_whitespace !==
+        this.diffPrefs?.ignore_whitespace ||
+      Boolean(this.originalDiffPrefs?.show_tabs) !==
+        Boolean(this.diffPrefs?.show_tabs) ||
+      Boolean(this.originalDiffPrefs?.show_whitespace_errors) !==
+        Boolean(this.diffPrefs?.show_whitespace_errors) ||
+      Boolean(this.originalDiffPrefs?.manual_review) !==
+        Boolean(this.diffPrefs?.manual_review)
     );
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleSyntaxHighlightTap() {
-    this.set(
-      'diffPrefs.syntax_highlighting',
-      this.$.syntaxHighlightInput.checked
-    );
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleAutomaticReviewTap() {
-    this.set('diffPrefs.manual_review', !this.$.automaticReviewInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleDiffIgnoreWhitespaceChanged() {
-    this.set(
-      'diffPrefs.ignore_whitespace',
-      this.$.ignoreWhiteSpace.value as IgnoreWhitespaceType
-    );
-    this._handleDiffPrefsChanged();
   }
 
   async save() {
     if (!this.diffPrefs) return;
     await this.userModel.updateDiffPreference(this.diffPrefs);
-    this.hasUnsavedChanges = false;
-  }
-
-  /**
-   * bind-value has type string so we have to convert
-   * anything inputed to string.
-   *
-   * This is so typescript checker doesn't fail.
-   */
-  _convertToString(key?: number | IgnoreWhitespaceType) {
-    return key !== undefined ? String(key) : '';
-  }
-
-  /**
-   * input 'checked' does not allow undefined,
-   * so we make sure the value is boolean
-   * by returning false if undefined.
-   *
-   * This is so typescript checker doesn't fail.
-   */
-  _convertToBoolean(key?: boolean) {
-    return key !== undefined ? key : false;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'has-unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-diff-preferences': GrDiffPreferences;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
deleted file mode 100644
index 51867c8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="diffPreferences" class="gr-form-styles">
-    <section>
-      <label for="contextLineSelect" class="title">Context</label>
-      <span class="value">
-        <gr-select
-          id="contextSelect"
-          bind-value="[[_convertToString(diffPrefs.context)]]"
-          on-change="_handleDiffContextChanged"
-        >
-          <select id="contextLineSelect">
-            <option value="3">3 lines</option>
-            <option value="10">10 lines</option>
-            <option value="25">25 lines</option>
-            <option value="50">50 lines</option>
-            <option value="75">75 lines</option>
-            <option value="100">100 lines</option>
-            <option value="-1">Whole file</option>
-          </select>
-        </gr-select>
-      </span>
-    </section>
-    <section>
-      <label for="lineWrappingInput" class="title">Fit to screen</label>
-      <span class="value">
-        <input
-          id="lineWrappingInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.line_wrapping)]]"
-          on-change="_handleLineWrappingTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="columnsInput" class="title">Diff width</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(diffPrefs.line_length)]]"
-          on-change="_handleDiffLineLengthChanged"
-        >
-          <input id="columnsInput" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="tabSizeInput" class="title">Tab width</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(diffPrefs.tab_size)]]"
-          on-change="_handleDiffTabSizeChanged"
-        >
-          <input id="tabSizeInput" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section hidden$="[[!diffPrefs.font_size]]">
-      <label for="fontSizeInput" class="title">Font size</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(diffPrefs.font_size)]]"
-          on-change="_handleDiffFontSizeChanged"
-        >
-          <input id="fontSizeInput" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="showTabsInput" class="title">Show tabs</label>
-      <span class="value">
-        <input
-          id="showTabsInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.show_tabs)]]"
-          on-change="_handleShowTabsTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showTrailingWhitespaceInput" class="title"
-        >Show trailing whitespace</label
-      >
-      <span class="value">
-        <input
-          id="showTrailingWhitespaceInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.show_whitespace_errors)]]"
-          on-change="_handleShowTrailingWhitespaceTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="syntaxHighlightInput" class="title"
-        >Syntax highlighting</label
-      >
-      <span class="value">
-        <input
-          id="syntaxHighlightInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.syntax_highlighting)]]"
-          on-change="_handleSyntaxHighlightTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="automaticReviewInput" class="title"
-        >Automatically mark viewed files reviewed</label
-      >
-      <span class="value">
-        <input
-          id="automaticReviewInput"
-          type="checkbox"
-          checked="[[!_convertToBoolean(diffPrefs.manual_review)]]"
-          on-change="_handleAutomaticReviewTap"
-        />
-      </span>
-    </section>
-    <section>
-      <div class="pref">
-        <label for="ignoreWhiteSpace" class="title">Ignore Whitespace</label>
-        <span class="value">
-          <gr-select
-            bind-value="[[_convertToString(diffPrefs.ignore_whitespace)]]"
-            on-change="_handleDiffIgnoreWhitespaceChanged"
-          >
-            <select id="ignoreWhiteSpace">
-              <option value="IGNORE_NONE">None</option>
-              <option value="IGNORE_TRAILING">Trailing</option>
-              <option value="IGNORE_LEADING_AND_TRAILING">
-                Leading &amp; trailing
-              </option>
-              <option value="IGNORE_ALL">All</option>
-            </select>
-          </gr-select>
-        </span>
-      </div>
-    </section>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
index 599cdb5..c32189e 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -18,11 +18,12 @@
 import '../../../test/common-test-setup-karma';
 import './gr-diff-preferences';
 import {GrDiffPreferences} from './gr-diff-preferences';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAll, stubRestApi} from '../../../test/test-utils';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {IronInputElement} from '@polymer/iron-input';
 import {GrSelect} from '../gr-select/gr-select';
+import {ParsedJSON} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-diff-preferences');
 
@@ -32,7 +33,7 @@
   let diffPreferences: DiffPreferencesInfo;
 
   function valueOf(title: string, id: string) {
-    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    const sections = queryAll(element, `#${id} section`) ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -51,7 +52,7 @@
 
     element = basicFixture.instantiate();
 
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -84,7 +85,7 @@
       <section>
         <label class="title" for="columnsInput">Diff width</label>
         <span class="value">
-          <iron-input allowed-pattern="[0-9]">
+          <iron-input>
             <input id="columnsInput" type="number" />
           </iron-input>
         </span>
@@ -92,7 +93,7 @@
       <section>
         <label class="title" for="tabSizeInput">Tab width</label>
         <span class="value">
-          <iron-input allowed-pattern="[0-9]">
+          <iron-input>
             <input id="tabSizeInput" type="number" />
           </iron-input>
         </span>
@@ -100,7 +101,7 @@
       <section>
         <label class="title" for="fontSizeInput">Font size</label>
         <span class="value">
-          <iron-input allowed-pattern="[0-9]">
+          <iron-input>
             <input id="fontSizeInput" type="number" />
           </iron-input>
         </span>
@@ -108,7 +109,7 @@
       <section>
         <label class="title" for="showTabsInput">Show tabs</label>
         <span class="value">
-          <input id="showTabsInput" type="checkbox" />
+          <input checked="" id="showTabsInput" type="checkbox" />
         </span>
       </section>
       <section>
@@ -116,7 +117,7 @@
           Show trailing whitespace
         </label>
         <span class="value">
-          <input id="showTrailingWhitespaceInput" type="checkbox" />
+          <input checked="" id="showTrailingWhitespaceInput" type="checkbox" />
         </span>
       </section>
       <section>
@@ -124,7 +125,7 @@
           Syntax highlighting
         </label>
         <span class="value">
-          <input id="syntaxHighlightInput" type="checkbox" />
+          <input checked="" id="syntaxHighlightInput" type="checkbox" />
         </span>
       </section>
       <section>
@@ -132,7 +133,7 @@
           Automatically mark viewed files reviewed
         </label>
         <span class="value">
-          <input id="automaticReviewInput" type="checkbox" />
+          <input checked="" id="automaticReviewInput" type="checkbox" />
         </span>
       </section>
       <section>
@@ -216,21 +217,32 @@
       diffPreferences.ignore_whitespace
     );
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(element.hasUnsavedChanges());
   });
 
   test('save changes', async () => {
+    assert.isTrue(element.diffPrefs!.show_whitespace_errors);
+
     const showTrailingWhitespaceCheckbox = valueOf(
       'Show trailing whitespace',
       'diffPreferences'
     ).firstElementChild as HTMLInputElement;
     showTrailingWhitespaceCheckbox.checked = false;
-    element._handleShowTrailingWhitespaceTap();
+    element.handleShowTrailingWhitespaceTap();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(element.hasUnsavedChanges());
+
+    const getResponseObjStub = stubRestApi('getResponseObject').returns(
+      Promise.resolve(element.diffPrefs! as unknown as ParsedJSON)
+    );
 
     // Save the change.
     await element.save();
-    assert.isFalse(element.hasUnsavedChanges);
+
+    assert.isTrue(getResponseObjStub.called);
+
+    assert.isFalse(element.diffPrefs!.show_whitespace_errors);
+
+    assert.isFalse(element.hasUnsavedChanges());
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 01a72ae..78de898 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -145,7 +145,7 @@
     return html`
       <paper-tabs
         id="downloadTabs"
-        class="${this.computeShowTabs()}"
+        class=${this.computeShowTabs()}
         .selected=${selectedIndex}
         @selected-changed=${this.handleTabChange}
       >
@@ -160,7 +160,7 @@
 
   private renderCommands() {
     return html`
-      <div class="commands" ?hidden="${!this.schemes.length}"></div>
+      <div class="commands" ?hidden=${!this.schemes.length}></div>
         ${this.commands?.map((command, index) =>
           this.renderShellCommand(command, index)
         )}
@@ -171,7 +171,7 @@
   private renderShellCommand(command: Command, index: number) {
     return html`
       <gr-shell-command
-        class="${this.computeClass(command.title)}"
+        class=${this.computeClass(command.title)}
         .label=${command.title}
         .command=${command.command}
         .tooltip=${this.computeTooltip(index)}
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index a1c2b0f..0416bf7 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -116,15 +116,15 @@
 
     assert.isNotOk(items[0].querySelector('gr-date-formatter'));
     assert.isNotOk(items[0].querySelector('.bottomContent'));
-    assert.equal(items[0].dataset.value, element.items![0].value as any);
-    assert.equal(mobileItems[0].value, element.items![0].value);
+    assert.equal(items[0].dataset.value, element.items[0].value as any);
+    assert.equal(mobileItems[0].value, element.items[0].value);
     assert.equal(
       queryAndAssert<HTMLDivElement>(items[0], '.topContent div').innerText,
-      element.items![0].text
+      element.items[0].text
     );
 
     // Since no mobile specific text, it should fall back to text.
-    assert.equal(mobileItems[0].text, element.items![0].text);
+    assert.equal(mobileItems[0].text, element.items[0].text);
 
     // Second Item
     // The second item should have top text, bottom text, and no date.
@@ -135,19 +135,19 @@
 
     assert.isNotOk(items[1].querySelector('gr-date-formatter'));
     assert.isOk(items[1].querySelector('.bottomContent'));
-    assert.equal(items[1].dataset.value, element.items![1].value as any);
-    assert.equal(mobileItems[1].value, element.items![1].value);
+    assert.equal(items[1].dataset.value, element.items[1].value as any);
+    assert.equal(mobileItems[1].value, element.items[1].value);
     assert.equal(
       queryAndAssert<HTMLDivElement>(items[1], '.topContent div').innerText,
-      element.items![1].text
+      element.items[1].text
     );
 
     // Since there is mobile specific text, it should that.
-    assert.equal(mobileItems[1].text, element.items![1].mobileText);
+    assert.equal(mobileItems[1].text, element.items[1].mobileText);
 
     // Since this item is selected, and it has triggerText defined, that
     // should be used.
-    assert.equal(element.text, element.items![1].triggerText);
+    assert.equal(element.text, element.items[1].triggerText);
 
     // Third item
     // The third item should be disabled, and have a date, and bottom content.
@@ -158,15 +158,15 @@
 
     assert.isOk(items[2].querySelector('gr-date-formatter'));
     assert.isOk(items[2].querySelector('.bottomContent'));
-    assert.equal(items[2].dataset.value, element.items![2].value as any);
-    assert.equal(mobileItems[2].value, element.items![2].value);
+    assert.equal(items[2].dataset.value, element.items[2].value as any);
+    assert.equal(mobileItems[2].value, element.items[2].value);
     assert.equal(
       queryAndAssert<HTMLDivElement>(items[2], '.topContent div').innerText,
-      element.items![2].text
+      element.items[2].text
     );
 
     // Since there is mobile specific text, it should that.
-    assert.equal(mobileItems[2].text, element.items![2].mobileText);
+    assert.equal(mobileItems[2].text, element.items[2].mobileText);
 
     // Select a new item.
     MockInteractions.tap(items[0]);
@@ -176,6 +176,6 @@
     assert.isTrue(mobileItems[0].selected);
 
     // Since no triggerText, the fallback is used.
-    assert.equal(element.text, element.items![0].text);
+    assert.equal(element.text, element.items[0].text);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 4045b6d..f62278a 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -139,9 +139,6 @@
       addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, () => this._handleTab())
-    );
-    this.cleanups.push(
       addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
     );
     this.cleanups.push(
@@ -179,13 +176,6 @@
   }
 
   /**
-   * Handle the tab key.
-   */
-  _handleTab() {
-    // Tab in a native select is a no-op. Emulate this.
-  }
-
-  /**
    * Handle the enter key.
    */
   _handleEnter() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 6191bd7..bdd2b52 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -20,15 +20,21 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-editable-content_html';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../utils/common-util';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {Interaction} from '../../../constants/reporting';
+import {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {PropertyValues} from 'lit';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {nothing} from 'lit';
+import {classMap} from 'lit/directives/class-map';
+import {when} from 'lit/directives/when';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -37,14 +43,14 @@
   interface HTMLElementTagNameMap {
     'gr-editable-content': GrEditableContent;
   }
+  interface HTMLElementEventMap {
+    'content-changed': ValueChangedEvent<string>;
+    'editing-changed': ValueChangedEvent<boolean>;
+  }
 }
 
 @customElement('gr-editable-content')
-export class GrEditableContent extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditableContent extends LitElement {
   /**
    * Fired when the save button is pressed.
    *
@@ -63,58 +69,35 @@
    * @event show-alert
    */
 
-  @property({type: String, notify: true, observer: '_contentChanged'})
+  @property({type: String})
   content?: string;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
   @property({
     type: Boolean,
-    observer: '_editingChanged',
-    notify: true,
-    reflectToAttribute: true,
+    reflect: true,
   })
   editing = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'remove-zero-width-space'})
   removeZeroWidthSpace?: boolean;
 
   // If no storage key is provided, content is not stored.
-  @property({type: String})
+  @property({type: String, attribute: 'storage-key'})
   storageKey?: string;
 
-  /** If false, then the "Show more" button was used to expand. */
-  @property({type: Boolean})
-  _commitCollapsed = true;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'commit-collapsible'})
   commitCollapsible = true;
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeHideShowAllContainer(hideEditCommitMessage, _hideShowAllButton, editing)',
-  })
-  _hideShowAllContainer = false;
-
-  @property({
-    type: Boolean,
-    computed: '_computeHideShowAllButton(commitCollapsible, editing)',
-  })
-  _hideShowAllButton = false;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'hide-edit-commit-message'})
   hideEditCommitMessage?: boolean;
 
-  @property({
-    type: Boolean,
-    computed: '_computeSaveDisabled(disabled, content, _newContent)',
-  })
-  _saveDisabled!: boolean;
+  /** If false, then the "Show more" button was used to expand. */
+  @state() commitCollapsed = true;
 
-  @property({type: String, observer: '_newContentChanged'})
-  _newContent = '';
+  @state() newContent = '';
 
   private readonly storage = getAppContext().storageService;
 
@@ -128,12 +111,210 @@
     super.disconnectedCallback();
   }
 
-  _contentChanged() {
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('editing')) this.editingChanged();
+    if (changedProperties.has('newContent')) this.newContentChanged();
+    if (changedProperties.has('content')) this.contentChanged();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) iron-autogrow-textarea {
+          opacity: 0.5;
+        }
+        .viewer {
+          background-color: var(--view-background-color);
+          border: 1px solid var(--view-background-color);
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-1);
+          padding: var(--spacing-m);
+        }
+        :host(.collapsed) .viewer,
+        .viewer.collapsed {
+          max-height: var(--collapsed-max-height, 300px);
+          overflow: hidden;
+        }
+        .editor iron-autogrow-textarea,
+        .viewer {
+          min-height: 100px;
+        }
+        .editor iron-autogrow-textarea {
+          background-color: var(--view-background-color);
+          width: 100%;
+          display: block;
+          --iron-autogrow-textarea_-_padding: var(--spacing-m);
+        }
+        .editButtons {
+          display: flex;
+          justify-content: space-between;
+        }
+        .show-all-container {
+          background-color: var(--view-background-color);
+          display: flex;
+          justify-content: flex-end;
+          border: 1px solid transparent;
+          border-top-color: var(--border-color);
+          border-radius: 0 0 4px 4px;
+          box-shadow: var(--elevation-level-1);
+          /* slightly up to cover rounded corner of the commit msg */
+          margin-top: calc(-1 * var(--spacing-xs));
+          /* To make this bar pop over editor, since editor has relative position.
+          */
+          position: relative;
+        }
+        :host([editing]) .show-all-container {
+          box-shadow: none;
+          border: 1px solid var(--border-color);
+        }
+        .flex-space {
+          flex-grow: 1;
+        }
+        .show-all-container iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        .cancel-button {
+          margin-right: var(--spacing-l);
+        }
+        .save-button {
+          margin-right: var(--spacing-xs);
+        }
+        gr-button {
+          font-family: var(--font-family);
+          line-height: var(--line-height-normal);
+          padding: var(--spacing-xs);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-endpoint-decorator name="commit-message">
+        <gr-endpoint-param
+          name="editing"
+          .value=${this.editing}
+        ></gr-endpoint-param>
+        ${this.renderViewer()} ${this.renderEditor()} ${this.renderButtons()}
+        <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderViewer() {
+    if (this.editing) return;
+    return html`
+      <div
+        class=${classMap({
+          viewer: true,
+          collapsed: this.commitCollapsed && this.commitCollapsible,
+        })}
+      >
+        <slot></slot>
+      </div>
+    `;
+  }
+
+  private renderEditor() {
+    if (!this.editing) return;
+    return html`
+      <div class="editor">
+        <div>
+          <iron-autogrow-textarea
+            autocomplete="on"
+            .bindValue=${this.newContent}
+            ?disabled=${this.disabled}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newContent = e.detail.value;
+            }}
+          ></iron-autogrow-textarea>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderButtons() {
+    if (!this.editing && !this.commitCollapsible && this.hideEditCommitMessage)
+      return nothing;
+
+    return html`
+      <div class="show-all-container">
+        ${when(
+          this.commitCollapsible && !this.editing,
+          () => html`
+            <gr-button
+              link
+              class="show-all-button"
+              @click=${this.toggleCommitCollapsed}
+            >
+              ${when(
+                !this.commitCollapsed,
+                () => html`
+                  <iron-icon icon="gr-icons:expand-less"></iron-icon>
+                `
+              )}
+              ${when(
+                this.commitCollapsed,
+                () => html`
+                  <iron-icon icon="gr-icons:expand-more"></iron-icon>
+                `
+              )}
+              ${this.commitCollapsed ? 'Show all' : 'Show less'}
+            </gr-button>
+            <div class="flex-space"></div>
+          `
+        )}
+        ${when(
+          !this.hideEditCommitMessage,
+          () => html`
+            <gr-button
+              link
+              class="edit-commit-message"
+              title="Edit commit message"
+              @click=${this.handleEditCommitMessage}
+              ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
+            >
+          `
+        )}
+        ${when(
+          this.editing,
+          () => html` <div class="editButtons">
+            <gr-button
+              link
+              class="cancel-button"
+              @click=${this.handleCancel}
+              ?disabled=${this.disabled}
+              >Cancel</gr-button
+            >
+            <gr-button
+              class="save-button"
+              primary=""
+              @click=${this.handleSave}
+              ?disabled=${this.computeSaveDisabled()}
+              >Save</gr-button
+            >
+          </div>`
+        )}
+        </div>
+      </div>
+    `;
+  }
+
+  contentChanged() {
     /* A changed content means that either a different change has been loaded
      * or new content was saved. Either way, let's reset the component.
      */
     this.editing = false;
-    this._newContent = '';
+    this.newContent = '';
+    fire(this, 'content-changed', {
+      value: this.content ?? '',
+    });
   }
 
   focusTextarea() {
@@ -143,15 +324,15 @@
     ).textarea.focus();
   }
 
-  _newContentChanged(newContent: string) {
+  newContentChanged() {
     if (!this.storageKey) return;
     const storageKey = this.storageKey;
 
     this.storeTask = debounce(
       this.storeTask,
       () => {
-        if (newContent.length) {
-          this.storage.setEditableContentItem(storageKey, newContent);
+        if (this.newContent.length) {
+          this.storage.setEditableContentItem(storageKey, this.newContent);
         } else {
           // This does not really happen, because we don't clear newContent
           // after saving (see below). So this only occurs when the user clears
@@ -165,8 +346,8 @@
     );
   }
 
-  _editingChanged(editing: boolean) {
-    // This method is for initializing _newContent when you start editing.
+  editingChanged() {
+    // This method is for initializing newContent when you start editing.
     // Restoring content from local storage is not perfect and has
     // some issues:
     //
@@ -180,7 +361,11 @@
     // content from local storage when you enter editing mode for the first
     // time. Otherwise it is better to just keep the last editing state from
     // the same session.
-    if (!editing || this._newContent) return;
+    fire(this, 'editing-changed', {
+      value: this.editing,
+    });
+
+    if (!this.editing || this.newContent) return;
 
     let content;
     if (this.storageKey) {
@@ -197,73 +382,51 @@
     }
 
     // TODO(wyatta) switch linkify sequence, see issue 5526.
-    this._newContent = this.removeZeroWidthSpace
+    this.newContent = this.removeZeroWidthSpace
       ? content.replace(/^R=\u200B/gm, 'R=')
       : content;
   }
 
-  _computeSaveDisabled(
-    disabled?: boolean,
-    content?: string,
-    newContent?: string
-  ): boolean {
-    return disabled || !newContent || content === newContent;
+  computeSaveDisabled(): boolean {
+    return (
+      this.disabled || !this.newContent || this.content === this.newContent
+    );
   }
 
-  _handleSave(e: Event) {
+  handleSave(e: Event) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('editable-content-save', {
-        detail: {content: this._newContent},
+        detail: {content: this.newContent},
         composed: true,
         bubbles: true,
       })
     );
-    // It would be nice, if we would set this._newContent = undefined here,
+    // It would be nice, if we would set this.newContent = undefined here,
     // but we can only do that when we are sure that the save operation has
     // succeeded.
   }
 
-  _handleCancel(e: Event) {
+  handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
     fireEvent(this, 'editable-content-cancel');
   }
 
-  _computeCollapseText(collapsed: boolean) {
-    return collapsed ? 'Show all' : 'Show less';
-  }
-
-  _toggleCommitCollapsed() {
-    this._commitCollapsed = !this._commitCollapsed;
+  toggleCommitCollapsed() {
+    this.commitCollapsed = !this.commitCollapsed;
     this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'Commit message',
-      toState: !this._commitCollapsed ? 'Show all' : 'Show less',
+      toState: !this.commitCollapsed ? 'Show all' : 'Show less',
     });
-    if (this._commitCollapsed) {
+    if (this.commitCollapsed) {
       window.scrollTo(0, 0);
     }
   }
 
-  _computeHideShowAllContainer(
-    hideEditCommitMessage?: boolean,
-    _hideShowAllButton?: boolean,
-    editing?: boolean
-  ) {
-    if (editing) return false;
-    return _hideShowAllButton && hideEditCommitMessage;
-  }
-
-  _computeHideShowAllButton(commitCollapsible?: boolean, editing?: boolean) {
-    return !commitCollapsible || editing;
-  }
-
-  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
-    return collapsible && collapsed;
-  }
-
-  _handleEditCommitMessage() {
+  async handleEditCommitMessage() {
     this.editing = true;
+    await this.updateComplete;
     this.focusTextarea();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
deleted file mode 100644
index 8c40177..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) iron-autogrow-textarea {
-      opacity: 0.5;
-    }
-    .viewer {
-      background-color: var(--view-background-color);
-      border: 1px solid var(--view-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-1);
-      padding: var(--spacing-m);
-    }
-    :host([collapsed]) .viewer,
-    .viewer[collapsed] {
-      max-height: var(--collapsed-max-height, 300px);
-      overflow: hidden;
-    }
-    .editor iron-autogrow-textarea,
-    .viewer {
-      min-height: 100px;
-    }
-    .editor iron-autogrow-textarea {
-      background-color: var(--view-background-color);
-      width: 100%;
-      display: block;
-
-      /* You have to also repeat everything from shared-styles here, because
-           you can only *replace* --iron-autogrow-textarea vars as a whole. */
-      --iron-autogrow-textarea: {
-        box-sizing: border-box;
-        padding: var(--spacing-m);
-        overflow-y: hidden;
-        white-space: pre;
-      }
-    }
-    .editButtons {
-      display: flex;
-      justify-content: space-between;
-    }
-    .show-all-container {
-      background-color: var(--view-background-color);
-      display: flex;
-      justify-content: flex-end;
-      border: 1px solid transparent;
-      border-top-color: var(--border-color);
-      border-radius: 0 0 4px 4px;
-      box-shadow: var(--elevation-level-1);
-      /* slightly up to cover rounded corner of the commit msg */
-      margin-top: calc(-1 * var(--spacing-xs));
-      /* To make this bar pop over editor, since editor has relative position.
-      */
-      position: relative;
-    }
-    :host([editing]) .show-all-container {
-      box-shadow: none;
-      border: 1px solid var(--border-color);
-    }
-    .show-all-container .show-all-button {
-      margin-right: auto;
-    }
-    .show-all-container iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .cancel-button {
-      margin-right: var(--spacing-l);
-    }
-    .save-button {
-      margin-right: var(--spacing-xs);
-    }
-    gr-button {
-      font-family: var(--font-family);
-      line-height: var(--line-height-normal);
-      padding: var(--spacing-xs);
-    }
-  </style>
-  <gr-endpoint-decorator name="commit-message">
-    <gr-endpoint-param name="editing" value="[[editing]]"></gr-endpoint-param>
-    <div
-      class="viewer"
-      hidden$="[[editing]]"
-      collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, commitCollapsible)]]"
-    >
-      <slot></slot>
-    </div>
-    <div class="editor" hidden$="[[!editing]]">
-      <div>
-        <iron-autogrow-textarea
-          autocomplete="on"
-          bind-value="{{_newContent}}"
-          disabled="[[disabled]]"
-        ></iron-autogrow-textarea>
-      </div>
-    </div>
-    <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
-    <div class="show-all-container" hidden$="[[_hideShowAllContainer]]">
-      <gr-button
-        link=""
-        class="show-all-button"
-        on-click="_toggleCommitCollapsed"
-        hidden$="[[_hideShowAllButton]]"
-        ><iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[!_commitCollapsed]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[_commitCollapsed]]"
-        ></iron-icon>
-        [[_computeCollapseText(_commitCollapsed)]]
-      </gr-button>
-      <gr-button
-        link=""
-        class="edit-commit-message"
-        title="Edit commit message"
-        on-click="_handleEditCommitMessage"
-        hidden$="[[hideEditCommitMessage]]"
-        ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
-      >
-      <div class="editButtons" hidden$="[[!editing]]">
-        <gr-button
-          link=""
-          class="cancel-button"
-          on-click="_handleCancel"
-          disabled="[[disabled]]"
-          >Cancel</gr-button
-        >
-        <gr-button
-          class="save-button"
-          primary=""
-          on-click="_handleSave"
-          disabled="[[_saveDisabled]]"
-          >Save</gr-button
-        >
-      </div>
-    </div>
-  </gr-endpoint-decorator>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 074678e..de00651 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -18,33 +18,104 @@
 import '../../../test/common-test-setup-karma';
 import './gr-editable-content';
 import {GrEditableContent} from './gr-editable-content';
-import {queryAndAssert, stubStorage} from '../../../test/test-utils';
+import {query, queryAndAssert, stubStorage} from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrButton} from '../gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-editable-content');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-editable-content tests', () => {
   let element: GrEditableContent;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-editable-content></gr-editable-content>`);
+    await element.updateComplete;
   });
 
-  test('save event', () => {
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<gr-endpoint-decorator
+      name="commit-message"
+    >
+      <gr-endpoint-param name="editing"> </gr-endpoint-param>
+      <div class="collapsed viewer">
+        <slot> </slot>
+      </div>
+      <div class="show-all-container">
+        <gr-button
+          aria-disabled="false"
+          class="show-all-button"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          <iron-icon icon="gr-icons:expand-more"> </iron-icon>
+          Show all
+        </gr-button>
+        <div class="flex-space"></div>
+        <gr-button
+          aria-disabled="false"
+          class="edit-commit-message"
+          link=""
+          role="button"
+          tabindex="0"
+          title="Edit commit message"
+        >
+          <iron-icon icon="gr-icons:edit"> </iron-icon>
+          Edit
+        </gr-button>
+      </div>
+      <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
+    </gr-endpoint-decorator> `);
+  });
+
+  test('show-all-container visibility', async () => {
+    element.editing = false;
+    element.commitCollapsible = false;
+    element.hideEditCommitMessage = true;
+    await element.updateComplete;
+    assert.isNotOk(query(element, '.show-all-container'));
+
+    element.hideEditCommitMessage = false;
+    await element.updateComplete;
+    assert.isOk(query(element, '.show-all-container'));
+
+    element.hideEditCommitMessage = true;
+    element.editing = true;
+    await element.updateComplete;
+    assert.isOk(query(element, '.show-all-container'));
+
+    element.editing = false;
+    element.commitCollapsible = true;
+    await element.updateComplete;
+    assert.isOk(query(element, '.show-all-container'));
+  });
+
+  test('save event', async () => {
     element.content = '';
-    element._newContent = 'foo';
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before newContentChanged is
+    // called
+    await element.updateComplete;
+
+    element.newContent = 'foo';
+    element.disabled = false;
+    element.editing = true;
     const handler = sinon.spy();
     element.addEventListener('editable-content-save', handler);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, 'gr-button[primary]').click();
+
+    await element.updateComplete;
 
     assert.isTrue(handler.called);
     assert.equal(handler.lastCall.args[0].detail.content, 'foo');
   });
 
-  test('cancel event', () => {
+  test('cancel event', async () => {
     const handler = sinon.spy();
+    element.editing = true;
+    await element.updateComplete;
     element.addEventListener('editable-content-cancel', handler);
 
     MockInteractions.tap(queryAndAssert(element, 'gr-button.cancel-button'));
@@ -52,32 +123,58 @@
     assert.isTrue(handler.called);
   });
 
-  test('enabling editing keeps old content', () => {
+  test('enabling editing keeps old content', async () => {
     element.content = 'current content';
-    element._newContent = 'old content';
+
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before newContentChanged is
+    // called
+    await element.updateComplete;
+
+    element.newContent = 'old content';
     element.editing = true;
-    assert.equal(element._newContent, 'old content');
+
+    await element.updateComplete;
+
+    assert.equal(element.newContent, 'old content');
   });
 
   test('disabling editing does not update edit field contents', () => {
     element.content = 'current content';
     element.editing = true;
-    element._newContent = 'stale content';
+    element.newContent = 'stale content';
     element.editing = false;
-    assert.equal(element._newContent, 'stale content');
+    assert.equal(element.newContent, 'stale content');
   });
 
-  test('zero width spaces are removed properly', () => {
+  test('zero width spaces are removed properly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
+
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before editingChanged is
+    // called
+
+    await element.updateComplete;
+
     element.editing = true;
-    assert.equal(element._newContent, 'R=test@google.com');
+
+    // editingChanged updates newContent so wait for it's observer
+    // to finish
+    await element.updateComplete;
+
+    assert.equal(element.newContent, 'R=test@google.com');
   });
 
   suite('editing', () => {
-    setup(() => {
+    setup(async () => {
       element.content = 'current content';
+      // Needed because contentChanged resets newContent
+      // contentChanged updates newContent as well so wait for that observer
+      // to finish before setting editing=true.
+      await element.updateComplete;
       element.editing = true;
+      await element.updateComplete;
     });
 
     test('save button is disabled initially', () => {
@@ -86,8 +183,9 @@
       );
     });
 
-    test('save button is enabled when content changes', () => {
-      element._newContent = 'new content';
+    test('save button is enabled when content changes', async () => {
+      element.newContent = 'new content';
+      await element.updateComplete;
       assert.isFalse(
         queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
       );
@@ -97,49 +195,60 @@
   suite('storageKey and related behavior', () => {
     let dispatchSpy: sinon.SinonSpy;
 
-    setup(() => {
+    setup(async () => {
       element.content = 'current content';
+      await element.updateComplete;
       element.storageKey = 'test';
       dispatchSpy = sinon.spy(element, 'dispatchEvent');
     });
 
-    test('editing toggled to true, has stored data', () => {
+    test('editing toggled to true, has stored data', async () => {
       stubStorage('getEditableContentItem').returns({
         message: 'stored content',
         updated: 0,
       });
       element.editing = true;
-
-      assert.equal(element._newContent, 'stored content');
+      await element.updateComplete;
+      assert.equal(element.newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.firstCall.args[0].type, 'show-alert');
+      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
     });
 
-    test('editing toggled to true, has no stored data', () => {
+    test('editing toggled to true, has no stored data', async () => {
       stubStorage('getEditableContentItem').returns(null);
       element.editing = true;
 
-      assert.equal(element._newContent, 'current content');
+      await element.updateComplete;
+
+      assert.equal(element.newContent, 'current content');
       assert.equal(dispatchSpy.firstCall.args[0].type, 'editing-changed');
     });
 
-    test('edits are cached', () => {
+    test('edits are cached', async () => {
       const storeStub = stubStorage('setEditableContentItem');
       const eraseStub = stubStorage('eraseEditableContentItem');
       element.editing = true;
 
-      element._newContent = 'new content';
-      flush();
+      // Needed because editingChanged resets newContent
+      // We want ediingChanged() to finish before triggering newContentChanged
+      await element.updateComplete;
+
+      element.newContent = 'new content';
+
+      await element.updateComplete;
+
       element.storeTask?.flush();
 
       assert.isTrue(storeStub.called);
       assert.deepEqual(
-        [element.storageKey, element._newContent],
+        [element.storageKey, element.newContent],
         storeStub.lastCall.args
       );
 
-      element._newContent = '';
-      flush();
+      element.newContent = '';
+
+      await element.updateComplete;
+
       element.storeTask?.flush();
 
       assert.isTrue(eraseStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 8564751..cd020a9 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -19,9 +19,6 @@
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../../shared/gr-autocomplete/gr-autocomplete';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-editable-label_html';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {PaperInputElementExt} from '../../../types/types';
 import {
@@ -30,6 +27,9 @@
 } from '../gr-autocomplete/gr-autocomplete';
 import {addShortcut, Key} from '../../../utils/dom-util';
 import {queryAndAssert} from '../../../utils/common-util';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -40,53 +40,45 @@
   }
 }
 
-export interface GrEditableLabel {
-  $: {
-    dropdown: IronDropdownElement;
-  };
-}
-
 @customElement('gr-editable-label')
-export class GrEditableLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditableLabel extends LitElement {
   /**
    * Fired when the value is changed.
    *
    * @event changed
    */
 
-  @property({type: String})
+  @query('#dropdown')
+  dropdown?: IronDropdownElement;
+
+  @property()
   labelText = '';
 
   @property({type: Boolean})
   editing = false;
 
-  @property({type: String, notify: true, observer: '_updateTitle'})
+  @property()
   value?: string;
 
-  @property({type: String})
+  @property()
   placeholder = '';
 
   @property({type: Boolean})
   readOnly = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   uppercase = false;
 
   @property({type: Number})
   maxLength?: number;
 
-  @property({type: String})
-  _inputText = '';
+  /* private but used in test */
+  @state() inputText = '';
 
   // This is used to push the iron-input element up on the page, so
   // the input is placed in approximately the same position as the
   // trigger.
-  @property({type: Number})
-  readonly _verticalOffset = -30;
+  @state() readonly verticalOffset = -30;
 
   @property({type: Boolean})
   showAsEditPencil = false;
@@ -97,9 +89,139 @@
   @property({type: Object})
   query: AutocompleteQuery = () => Promise.resolve([]);
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('tabindex', '0');
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          align-items: center;
+          display: inline-flex;
+        }
+        :host([uppercase]) label {
+          text-transform: uppercase;
+        }
+        input,
+        label {
+          width: 100%;
+        }
+        label {
+          color: var(--deemphasized-text-color);
+          display: inline-block;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        label.editable {
+          color: var(--link-color);
+          cursor: pointer;
+        }
+        #dropdown {
+          box-shadow: var(--elevation-level-2);
+        }
+        .inputContainer {
+          background-color: var(--dialog-background-color);
+          padding: var(--spacing-m);
+        }
+        .buttons {
+          display: flex;
+          justify-content: flex-end;
+          padding-top: var(--spacing-l);
+          width: 100%;
+        }
+        .buttons gr-button {
+          margin-left: var(--spacing-m);
+        }
+        paper-input {
+          --paper-input-container: {
+            padding: 0;
+            min-width: 15em;
+          }
+          --paper-input-container-input: {
+            font-size: inherit;
+          }
+          --paper-input-container-focus-color: var(--link-color);
+        }
+        gr-button iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        gr-button.pencil {
+          --gr-button-padding: 0px 0px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    this.setAttribute('title', this.computeLabel());
+    return html`${this.renderActivateButton()}
+      <iron-dropdown
+        id="dropdown"
+        .verticalAlign=${'auto'}
+        .horizontalAlign=${'auto'}
+        .verticalOffset=${this.verticalOffset}
+        allowOutsideScroll
+        @iron-overlay-canceled=${this.cancel}
+      >
+        <div class="dropdown-content" slot="dropdown-content">
+          <div class="inputContainer" part="input-container">
+            ${this.renderInputBox()}
+            <div class="buttons">
+              <gr-button link="" id="cancelBtn" @click=${this.cancel}
+                >cancel</gr-button
+              >
+              <gr-button link="" id="saveBtn" @click=${this.save}
+                >save</gr-button
+              >
+            </div>
+          </div>
+        </div>
+      </iron-dropdown>`;
+  }
+
+  private renderActivateButton() {
+    if (this.showAsEditPencil) {
+      return html`<gr-button
+        link=""
+        class="pencil ${this.computeLabelClass()}"
+        @click=${this.showDropdown}
+        title=${this.computeLabel()}
+        ><iron-icon icon="gr-icons:edit"></iron-icon
+      ></gr-button>`;
+    } else {
+      return html`<label
+        class=${this.computeLabelClass()}
+        title=${this.computeLabel()}
+        aria-label=${this.computeLabel()}
+        @click=${this.showDropdown}
+        part="label"
+        >${this.computeLabel()}</label
+      >`;
+    }
+  }
+
+  private renderInputBox() {
+    if (this.autocomplete) {
+      return html`<gr-autocomplete
+        .label=${this.labelText}
+        id="autocomplete"
+        .text=${this.inputText}
+        .query=${this.query}
+        @commit=${this.handleCommit}
+        @text-changed=${(e: CustomEvent) => {
+          this.inputText = e.detail.value;
+        }}
+      >
+      </gr-autocomplete>`;
+    } else {
+      return html`<paper-input
+        id="input"
+        .label=${this.labelText}
+        .maxlength=${this.maxLength}
+        .value=${this.inputText}
+      ></paper-input>`;
+    }
   }
 
   /** Called in disconnectedCallback. */
@@ -113,48 +235,55 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    if (!this.getAttribute('tabindex')) {
+      this.setAttribute('tabindex', '0');
+    }
+    if (!this.getAttribute('id')) {
+      this.setAttribute('id', 'global');
+    }
     this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+      addShortcut(this, {key: Key.ENTER}, e => this.handleEnter(e))
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+      addShortcut(this, {key: Key.ESC}, e => this.handleEsc(e))
     );
   }
 
-  _usePlaceholder(value?: string, placeholder?: string) {
+  private usePlaceholder(value?: string, placeholder?: string) {
     return (!value || !value.length) && placeholder;
   }
 
-  _computeLabel(value?: string, placeholder?: string): string {
-    if (this._usePlaceholder(value, placeholder)) {
-      return placeholder!;
+  private computeLabel(): string {
+    const {value, placeholder} = this;
+    if (this.usePlaceholder(value, placeholder)) {
+      return placeholder;
     }
     return value || '';
   }
 
-  _showDropdown() {
+  private showDropdown() {
     if (this.readOnly || this.editing) return;
-    return this._open().then(() => {
-      this._nativeInput.focus();
+    return this.openDropdown().then(() => {
+      this.nativeInput.focus();
       const input = this.getInput();
       if (!input?.value) return;
-      this._nativeInput.setSelectionRange(0, input.value.length);
+      this.nativeInput.setSelectionRange(0, input.value.length);
     });
   }
 
   open() {
-    return this._open().then(() => {
-      this._nativeInput.focus();
+    return this.openDropdown().then(() => {
+      this.nativeInput.focus();
     });
   }
 
-  _open() {
-    this.$.dropdown.open();
-    this._inputText = this.value || '';
+  private openDropdown() {
+    this.dropdown?.open();
+    this.inputText = this.value || '';
     this.editing = true;
 
     return new Promise<void>(resolve => {
-      this._awaitOpen(resolve);
+      this.awaitOpen(resolve);
     });
   }
 
@@ -162,11 +291,11 @@
    * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
    * opening. Eventually replace with a direct way to listen to the overlay.
    */
-  _awaitOpen(fn: () => void) {
+  private awaitOpen(fn: () => void) {
     let iters = 0;
     const step = () => {
       setTimeout(() => {
-        if (this.$.dropdown.style.display !== 'none') {
+        if (this.dropdown?.style.display !== 'none') {
           fn.call(this);
         } else if (iters++ < AWAIT_MAX_ITERS) {
           step.call(this);
@@ -176,16 +305,17 @@
     step.call(this);
   }
 
-  _id() {
-    return this.getAttribute('id') || 'global';
-  }
-
-  _save() {
+  private save() {
     if (!this.editing) {
       return;
     }
-    this.$.dropdown.close();
-    this.value = this._inputText || '';
+    this.dropdown?.close();
+    const input = this.getInput();
+    if (input) {
+      this.value = input.value ?? undefined;
+    } else {
+      this.value = this.inputText || '';
+    }
     this.editing = false;
     this.dispatchEvent(
       new CustomEvent('changed', {
@@ -196,23 +326,22 @@
     );
   }
 
-  _cancel() {
+  private cancel() {
     if (!this.editing) {
       return;
     }
-    this.$.dropdown.close();
+    this.dropdown?.close();
     this.editing = false;
-    this._inputText = this.value || '';
+    this.inputText = this.value || '';
   }
 
-  get _nativeInput(): HTMLInputElement {
-    // In Polymer 2 inputElement isn't nativeInput anymore
+  private get nativeInput(): HTMLInputElement {
     return (this.getInput()?.$.nativeInput ||
       this.getInput()?.inputElement ||
       this.getGrAutocomplete()) as HTMLInputElement;
   }
 
-  _handleEnter(event: KeyboardEvent) {
+  private handleEnter(event: KeyboardEvent) {
     const grAutocomplete = this.getGrAutocomplete();
     if (event.composedPath().some(el => el === grAutocomplete)) {
       return;
@@ -222,39 +351,36 @@
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      this._save();
+      this.save();
     }
   }
 
-  _handleEsc(event: KeyboardEvent) {
+  private handleEsc(event: KeyboardEvent) {
     const inputContainer = queryAndAssert(this, '.inputContainer');
     const isEventFromInput = event
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      this._cancel();
+      this.cancel();
     }
   }
 
-  _handleCommit() {
+  private handleCommit() {
     this.getInput()?.focus();
   }
 
-  _computeLabelClass(readOnly?: boolean, value?: string, placeholder?: string) {
+  private computeLabelClass() {
+    const {readOnly, value, placeholder} = this;
     const classes = [];
     if (!readOnly) {
       classes.push('editable');
     }
-    if (this._usePlaceholder(value, placeholder)) {
+    if (this.usePlaceholder(value, placeholder)) {
       classes.push('placeholder');
     }
     return classes.join(' ');
   }
 
-  _updateTitle(value?: string) {
-    this.setAttribute('title', this._computeLabel(value, this.placeholder));
-  }
-
   getInput(): PaperInputElementExt | null {
     return this.shadowRoot!.querySelector<PaperInputElementExt>('#input');
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
deleted file mode 100644
index e711e9d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: inline-flex;
-    }
-    :host([uppercase]) label {
-      text-transform: uppercase;
-    }
-    input,
-    label {
-      width: 100%;
-    }
-    label {
-      color: var(--deemphasized-text-color);
-      display: inline-block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    label.editable {
-      color: var(--link-color);
-      cursor: pointer;
-    }
-    #dropdown {
-      box-shadow: var(--elevation-level-2);
-    }
-    .inputContainer {
-      background-color: var(--dialog-background-color);
-      padding: var(--spacing-m);
-    }
-    .buttons {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: var(--spacing-l);
-      width: 100%;
-    }
-    .buttons gr-button {
-      margin-left: var(--spacing-m);
-    }
-    paper-input {
-      --paper-input-container: {
-        padding: 0;
-        min-width: 15em;
-      }
-      --paper-input-container-input: {
-        font-size: inherit;
-      }
-      --paper-input-container-focus-color: var(--link-color);
-    }
-    gr-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    gr-button.pencil {
-      --gr-button-padding: 0px 0px;
-    }
-  </style>
-  <template is="dom-if" if="[[!showAsEditPencil]]">
-    <label
-      class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-      title$="[[_computeLabel(value, placeholder)]]"
-      aria-label$="[[_computeLabel(value, placeholder)]]"
-      on-click="_showDropdown"
-      part="label"
-      >[[_computeLabel(value, placeholder)]]</label
-    >
-  </template>
-  <template is="dom-if" if="[[showAsEditPencil]]">
-    <gr-button
-      link=""
-      class$="pencil [[_computeLabelClass(readOnly, value, placeholder)]]"
-      on-click="_showDropdown"
-      title="[[_computeLabel(value, placeholder)]]"
-      ><iron-icon icon="gr-icons:edit"></iron-icon
-    ></gr-button>
-  </template>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="auto"
-    horizontal-align="auto"
-    vertical-offset="[[_verticalOffset]]"
-    allow-outside-scroll="true"
-    on-iron-overlay-canceled="_cancel"
-  >
-    <div class="dropdown-content" slot="dropdown-content">
-      <div class="inputContainer" part="input-container">
-        <template is="dom-if" if="[[!autocomplete]]">
-          <paper-input
-            id="input"
-            label="[[labelText]]"
-            maxlength="[[maxLength]]"
-            value="{{_inputText}}"
-          ></paper-input>
-        </template>
-        <template is="dom-if" if="[[autocomplete]]">
-          <gr-autocomplete
-            label="[[labelText]]"
-            id="autocomplete"
-            text="{{_inputText}}"
-            query="[[query]]"
-            on-commit="_handleCommit"
-          >
-          </gr-autocomplete>
-        </template>
-        <div class="buttons">
-          <gr-button link="" id="cancelBtn" on-click="_cancel"
-            >cancel</gr-button
-          >
-          <gr-button link="" id="saveBtn" on-click="_save">save</gr-button>
-        </div>
-      </div>
-    </div>
-  </iron-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
similarity index 60%
rename from polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
rename to polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index 8334714..a439d05 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -15,43 +15,38 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-editable-label.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-editable-label
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-`);
-
-const noPlaceholderFixture = fixtureFromTemplate(html`
-<gr-editable-label value=""></gr-editable-label>
-`);
-
-const readOnlyFixture = fixtureFromTemplate(html`
-<gr-editable-label
-        read-only
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-`);
+import '../../../test/common-test-setup-karma';
+import './gr-editable-label';
+import {GrEditableLabel} from './gr-editable-label';
+import {queryAndAssert} from '../../../utils/common-util';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {GrButton} from '../gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
 
 suite('gr-editable-label tests', () => {
-  let element;
-  let elementNoPlaceholder;
-  let input;
-  let label;
+  let element: GrEditableLabel;
+  let elementNoPlaceholder: GrEditableLabel;
+  let input: HTMLInputElement;
+  let label: HTMLLabelElement;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    elementNoPlaceholder = noPlaceholderFixture.instantiate();
-    flush();
-    label = element.shadowRoot.querySelector('label');
+    element = await fixture<GrEditableLabel>(html`
+      <gr-editable-label
+        value="value text"
+        placeholder="label text"
+      ></gr-editable-label>
+    `);
+    elementNoPlaceholder = await fixture<GrEditableLabel>(html`
+      <gr-editable-label value=""></gr-editable-label>
+    `);
+    label = queryAndAssert<HTMLLabelElement>(element, 'label');
 
-    await flush();
     // In Polymer 2 inputElement isn't nativeInput anymore
-    const paperInput = element.shadowRoot.querySelector('#input');
-    input = paperInput.$.nativeInput || paperInput.inputElement;
+    const paperInput = queryAndAssert<PaperInputElement>(element, '#input');
+    input = (paperInput.$.nativeInput ||
+      paperInput.inputElement) as HTMLInputElement;
   });
 
   test('renders', () => {
@@ -63,10 +58,8 @@
     >
       value text
     </label>
-    <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-    <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
     <iron-dropdown
-      allow-outside-scroll="true"
+      allowoutsidescroll=""
       aria-disabled="false"
       aria-hidden="true"
       horizontal-align="auto"
@@ -81,10 +74,6 @@
             id="input"
             tabindex="0"
           ></paper-input>
-          <dom-if style="display: none;"><template is="dom-if"></template>
-          </dom-if>
-          <dom-if style="display: none;"><template is="dom-if"></template>
-          </dom-if>
           <div class="buttons">
             <gr-button
               aria-disabled="false"
@@ -110,37 +99,33 @@
     </iron-dropdown>`);
   });
 
-  test('element render', () => {
+  test('element render', async () => {
     // The dropdown is closed and the label is visible:
-    assert.isFalse(element.$.dropdown.opened);
+    const dropdown = queryAndAssert<IronDropdownElement>(element, '#dropdown');
+    assert.isFalse(dropdown.opened);
     assert.isTrue(label.classList.contains('editable'));
     assert.equal(label.textContent, 'value text');
-    const focusSpy = sinon.spy(input, 'focus');
-    const showSpy = sinon.spy(element, '_showDropdown');
 
-    MockInteractions.tap(label);
-
-    return showSpy.lastCall.returnValue.then(() => {
-      // The dropdown is open (which covers up the label):
-      assert.isTrue(element.$.dropdown.opened);
-      assert.isTrue(focusSpy.called);
-      assert.equal(input.value, 'value text');
-    });
+    label.click();
+    await element.updateComplete;
+    // The dropdown is open (which covers up the label):
+    assert.isTrue(dropdown.opened);
+    assert.equal(input.value, 'value text');
   });
 
-  test('title with placeholder', () => {
+  test('title with placeholder', async () => {
     assert.equal(element.title, 'value text');
     element.value = '';
 
-    flush();
+    await element.updateComplete;
     assert.equal(element.title, 'label text');
   });
 
-  test('title without placeholder', () => {
+  test('title without placeholder', async () => {
     assert.equal(elementNoPlaceholder.title, '');
     element.value = 'value text';
 
-    flush();
+    await flush();
     assert.equal(element.title, 'value text');
   });
 
@@ -150,57 +135,62 @@
     assert.isFalse(element.editing);
 
     MockInteractions.tap(label);
-    flush();
+    await flush();
 
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press enter:
     MockInteractions.keyDownOn(input, 13, null, 'Enter');
-    flush();
+    await flush();
 
     assert.isTrue(editedSpy.called);
     assert.equal(input.value, 'new text');
     assert.isFalse(element.editing);
   });
 
-  test('save button', () => {
+  test('save button', async () => {
     const editedSpy = sinon.spy();
     element.addEventListener('changed', editedSpy);
     assert.isFalse(element.editing);
 
     MockInteractions.tap(label);
-    flush();
+    await flush();
 
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press enter:
-    MockInteractions.tap(element.$.saveBtn, 13, null, 'Enter');
-    flush();
+    MockInteractions.pressAndReleaseKeyOn(
+      queryAndAssert<GrButton>(element, '#saveBtn'),
+      13,
+      null,
+      'Enter'
+    );
+    await flush();
 
     assert.isTrue(editedSpy.called);
     assert.equal(input.value, 'new text');
     assert.isFalse(element.editing);
   });
 
-  test('edit and then escape key', () => {
+  test('edit and then escape key', async () => {
     const editedSpy = sinon.spy();
     element.addEventListener('changed', editedSpy);
     assert.isFalse(element.editing);
 
     MockInteractions.tap(label);
-    flush();
+    await flush();
 
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press escape:
     MockInteractions.keyDownOn(input, 27, null, 'Escape');
-    flush();
+    await flush();
 
     assert.isFalse(editedSpy.called);
     // Text changes should be discarded.
@@ -208,21 +198,21 @@
     assert.isFalse(element.editing);
   });
 
-  test('cancel button', () => {
+  test('cancel button', async () => {
     const editedSpy = sinon.spy();
     element.addEventListener('changed', editedSpy);
     assert.isFalse(element.editing);
 
     MockInteractions.tap(label);
-    flush();
+    await flush();
 
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press escape:
-    MockInteractions.tap(element.$.cancelBtn);
-    flush();
+    MockInteractions.tap(queryAndAssert<GrButton>(element, '#cancelBtn'));
+    await flush();
 
     assert.isFalse(editedSpy.called);
     // Text changes should be discarded.
@@ -231,25 +221,33 @@
   });
 
   suite('gr-editable-label read-only tests', () => {
-    let element;
-    let label;
+    let element: GrEditableLabel;
+    let label: HTMLLabelElement;
 
-    setup(() => {
-      element = readOnlyFixture.instantiate();
-      flush();
-      label = element.shadowRoot
-          .querySelector('label');
+    setup(async () => {
+      element = await fixture<GrEditableLabel>(html`
+        <gr-editable-label
+          readOnly
+          value="value text"
+          placeholder="label text"
+        ></gr-editable-label>
+      `);
+      label = queryAndAssert(element, 'label');
     });
 
-    test('disallows edit when read-only', () => {
+    test('disallows edit when read-only', async () => {
       // The dropdown is closed.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(label);
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        '#dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+      label.click();
 
-      flush();
+      await element.updateComplete;
 
       // The dropdown is still closed.
-      assert.isFalse(element.$.dropdown.opened);
+      assert.isFalse(dropdown.opened);
     });
 
     test('label is not marked as editable', () => {
@@ -257,4 +255,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
index 3f759b5..a2088cc 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
@@ -67,10 +67,10 @@
 
   override render() {
     return html` <span
-      class="${this._computeStatusClass(this.file)}"
+      class=${this._computeStatusClass(this.file)}
       tabindex="0"
-      title="${this._computeFileStatusLabel(this.file?.status)}"
-      aria-label="${this._computeFileStatusLabel(this.file?.status)}"
+      title=${this._computeFileStatusLabel(this.file?.status)}
+      aria-label=${this._computeFileStatusLabel(this.file?.status)}
     >
       ${this._computeFileStatusLabel(this.file?.status)}
     </span>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index c6f44af..ae6f001 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -90,6 +90,9 @@
           display: block;
           font-family: var(--font-family);
         }
+        a {
+          color: var(--link-color);
+        }
         p,
         ul,
         code,
@@ -127,13 +130,11 @@
           list-style-type: disc;
           margin-left: var(--spacing-xl);
         }
-        code,
-        gr-linked-text.pre {
+        .inline-code,
+        code {
           font-family: var(--monospace-font-family);
           font-size: var(--font-size-code);
           line-height: var(--line-height-mono);
-        }
-        gr-linked-text.pre {
           background-color: var(--background-color-secondary);
           border: 1px solid var(--border-color);
           padding: 1px var(--spacing-s);
@@ -350,21 +351,9 @@
     return /^\s+$/.test(line);
   }
 
-  private renderText(content: string, isPre?: boolean): TemplateResult {
+  private renderInlineText(content: string): TemplateResult {
     return html`
       <gr-linked-text
-        class="${isPre ? 'pre' : ''}"
-        .config=${this.config}
-        content=${content}
-        pre
-      ></gr-linked-text>
-    `;
-  }
-
-  private renderInlineText(content: string, isPre?: boolean): TemplateResult {
-    return html`
-      <gr-linked-text
-        class="${isPre ? 'pre' : ''}"
         .config=${this.config}
         content=${content}
         pre
@@ -374,7 +363,11 @@
   }
 
   private renderLink(text: string, url: string): TemplateResult {
-    return html`<a href="${url}">${text}</a>`;
+    return html`<a href=${url}>${text}</a>`;
+  }
+
+  private renderInlineCode(text: string): TemplateResult {
+    return html`<span class="inline-code">${text}</span>`;
   }
 
   private renderInlineItem(span: InlineItem): TemplateResult {
@@ -384,7 +377,7 @@
       case 'link':
         return this.renderLink(span.text, span.url);
       case 'code':
-        return this.renderInlineText(span.text, true);
+        return this.renderInlineCode(span.text);
       default:
         return html``;
     }
@@ -411,7 +404,7 @@
       case 'code':
         return html`<code>${block.text}</code>`;
       case 'pre':
-        return this.renderText(block.text, true);
+        return html`<pre><code>${block.text}</code></pre>`;
       case 'list':
         return html`
           <ul>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 9d763d0..ce5ef4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -214,7 +214,7 @@
           class="removeReviewerOrCC"
           link=""
           no-uppercase
-          @click="${this.handleRemoveReviewerOrCC}"
+          @click=${this.handleRemoveReviewerOrCC}
         >
           Remove ${this.computeReviewerOrCCText()}
         </gr-button>
@@ -224,7 +224,7 @@
           class="changeReviewerOrCC"
           link=""
           no-uppercase
-          @click="${this.handleChangeReviewerOrCCStatus}"
+          @click=${this.handleChangeReviewerOrCCStatus}
         >
           ${this.computeChangeReviewerOrCCText()}
         </gr-button>
@@ -237,7 +237,7 @@
       <gr-endpoint-decorator name="hovercard-status">
         <gr-endpoint-param
           name="account"
-          .value="${this.account}"
+          .value=${this.account}
         ></gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
@@ -247,7 +247,7 @@
     return html` <div class="links">
       <iron-icon class="linkIcon" icon="gr-icons:link"></iron-icon
       ><a
-        href="${ifDefined(this.computeOwnerChangesLink())}"
+        href=${ifDefined(this.computeOwnerChangesLink())}
         @click=${() => {
           this.forceHide();
           return true;
@@ -258,7 +258,7 @@
         }}
         >Changes</a
       >·<a
-        href="${ifDefined(this.computeOwnerDashboardLink())}"
+        href=${ifDefined(this.computeOwnerDashboardLink())}
         @click=${() => {
           this.forceHide();
           return true;
@@ -292,7 +292,7 @@
             class="attentionIcon"
             icon="gr-icons:attention"
           ></iron-icon>
-          <span> ${this.computePronoun()} turn to take this action. </span>
+          <span> ${this.computePronoun()} turn to take action. </span>
           <a
             href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
             target="_blank"
@@ -311,7 +311,7 @@
           ${lastUpdate
             ? html` (<gr-date-formatter
                   withTooltip
-                  .dateStr="${lastUpdate}"
+                  .dateStr=${lastUpdate}
                 ></gr-date-formatter
                 >)`
             : ''}
@@ -328,7 +328,7 @@
           class="addToAttentionSet"
           link=""
           no-uppercase
-          @click="${this.handleClickAddToAttentionSet}"
+          @click=${this.handleClickAddToAttentionSet}
         >
           Add to attention set
         </gr-button>
@@ -344,7 +344,7 @@
           class="removeFromAttentionSet"
           link=""
           no-uppercase
-          @click="${this.handleClickRemoveFromAttentionSet}"
+          @click=${this.handleClickRemoveFromAttentionSet}
         >
           Remove from attention set
         </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
index f98ad70..77fc4f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -71,7 +71,7 @@
   });
 
   teardown(async () => {
-    await element.mouseHide(new MouseEvent('click'));
+    element.mouseHide(new MouseEvent('click'));
     await element.updateComplete;
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 4456381..dc2cbc7 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -25,7 +25,7 @@
       <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=unfold_more -->
       <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
@@ -61,11 +61,11 @@
       <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mode_comment-->
       <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=calendar_today-->
       <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
@@ -77,11 +77,13 @@
       <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="build"><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle-->
       <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle_outline-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle_outline-->
       <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
       <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=check+circle-->
       <g id="check-circle-filled"><path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M10,17l-4-4l1.4-1.4l2.6,2.6l6.6-6.6 L18,9L10,17z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
@@ -110,45 +112,45 @@
       <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
-      <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
+      <!-- This SVG is an adaptation of material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=label_important-->
       <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=pets-->
       <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=visibility-->
       <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
       <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=bug_report-->
       <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
       <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#warning-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=warning-->
       <g id="warning"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#timelapse-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=timelapse-->
       <g id="timelapse"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.24 7.76C15.07 6.59 13.54 6 12 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0 2.34-2.34 2.34-6.14-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mark_chat_read-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mark_chat_read-->
       <g id="markChatRead"><path d="M12,18l-6,0l-4,4V4c0-1.1,0.9-2,2-2h16c1.1,0,2,0.9,2,2v7l-2,0V4H4v12l8,0V18z M23,14.34l-1.41-1.41l-4.24,4.24l-2.12-2.12 l-1.41,1.41L17.34,20L23,14.34z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#message-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=message-->
       <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#launch-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=launch-->
       <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=filter-->
       <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_down-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_down-->
       <g id="arrowDropDown"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 10l5 5 5-5z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_up-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_up-->
       <g id="arrowDropUp"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 14l5-5 5 5z"/></g>
       <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
       <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#insert_photo-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=insert_photo-->
       <g id="insert-photo"><path d="M0 0h24v24H0z" fill="none"/><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#download-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=download-->
       <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#system_update-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=system_update-->
       <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#swap_horiz-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=swap_horiz-->
       <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#link-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=link-->
       <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
       <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index 5e84601..f919898 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -62,7 +62,7 @@
 
   // Pathname should normally look like this:
   // /plugins/PLUGINNAME/static/SCRIPTNAME.js
-  // Or, for app/samples:
+  // Or:
   // /plugins/PLUGINNAME.js
   // TODO(taoalpha): guard with a regex
   return pathname.split('/')[2].split('.')[0];
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 36e2280..79c73f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -25,6 +25,7 @@
   PrimaryActionKey,
   RevisionActions,
 } from '../../../api/change-actions';
+import {PropertyDeclaration} from 'lit';
 
 export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
   __key: string;
@@ -40,7 +41,6 @@
   ChangeActions: Record<string, string>;
   ActionType: Record<string, string>;
   primaryActionKeys: string[];
-  push(propName: 'primaryActionKeys', value: string): void;
   hideQuickApproveAction(): void;
   setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
   setActionPriority(
@@ -57,6 +57,11 @@
     value: UIActionInfo[T]
   ): void;
   getActionDetails(actionName: string): ActionInfo | undefined;
+  requestUpdate(
+    name?: PropertyKey,
+    oldValue?: unknown,
+    options?: PropertyDeclaration
+  ): void;
 }
 
 export class GrChangeActionsInterface implements ChangeActionsPluginApi {
@@ -111,7 +116,8 @@
       return;
     }
 
-    el.push('primaryActionKeys', key);
+    el.primaryActionKeys.push(key);
+    el.requestUpdate();
   }
 
   removePrimaryActionKey(key: string) {
@@ -128,19 +134,19 @@
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
     this.reporting.trackApi(this.plugin, 'actions', 'setActionOverflow');
     // TODO(TS): remove return, unclear why it was written
-    return this.ensureEl().setActionOverflow(type, key, overflow);
+    this.ensureEl().setActionOverflow(type, key, overflow);
   }
 
   setActionPriority(type: ActionType, key: string, priority: ActionPriority) {
     this.reporting.trackApi(this.plugin, 'actions', 'setActionPriority');
     // TODO(TS): remove return, unclear why it was written
-    return this.ensureEl().setActionPriority(type, key, priority);
+    this.ensureEl().setActionPriority(type, key, priority);
   }
 
   setActionHidden(type: ActionType, key: string, hidden: boolean) {
     this.reporting.trackApi(this.plugin, 'actions', 'setActionHidden');
     // TODO(TS): remove return, unclear why it was written
-    return this.ensureEl().setActionHidden(type, key, hidden);
+    this.ensureEl().setActionHidden(type, key, hidden);
   }
 
   add(type: ActionType, label: string): string {
@@ -151,7 +157,7 @@
   remove(key: string) {
     this.reporting.trackApi(this.plugin, 'actions', 'remove');
     // TODO(TS): remove return, unclear why it was written
-    return this.ensureEl().removeActionButton(key);
+    this.ensureEl().removeActionButton(key);
   }
 
   addTapListener(key: string, handler: EventListenerOrEventListenerObject) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
deleted file mode 100644
index b70c8ca..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../change/gr-change-actions/gr-change-actions.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {getPluginLoader} from './gr-plugin-loader.js';
-const basicFixture = fixtureFromElement('gr-change-actions');
-
-suite('gr-change-actions-js-api-interface tests', () => {
-  let element;
-  let changeActions;
-  let plugin;
-
-  // Because deepEqual doesn’t behave in Safari.
-  function assertArraysEqual(actual, expected) {
-    assert.equal(actual.length, expected.length);
-    for (let i = 0; i < actual.length; i++) {
-      assert.equal(actual[i], expected[i]);
-    }
-  }
-
-  suite('early init', () => {
-    setup(() => {
-      resetPlugins();
-      window.Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      // Mimic all plugins loaded.
-      getPluginLoader().loadPlugins([]);
-      changeActions = plugin.changeActions();
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => {
-      changeActions = null;
-      resetPlugins();
-    });
-
-    test('does not throw', ()=> {
-      assert.doesNotThrow(() => {
-        changeActions.add('change', 'foo');
-      });
-    });
-  });
-
-  suite('normal init', () => {
-    setup(() => {
-      resetPlugins();
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_editStatusChanged');
-      element.change = {};
-      element._hasKnownChainState = false;
-      window.Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeActions = plugin.changeActions();
-      // Mimic all plugins loaded.
-      getPluginLoader().loadPlugins([]);
-    });
-
-    teardown(() => {
-      changeActions = null;
-      resetPlugins();
-    });
-
-    test('property existence', () => {
-      const properties = [
-        'ActionType',
-        'ChangeActions',
-        'RevisionActions',
-      ];
-      for (const p of properties) {
-        assertArraysEqual(changeActions[p], element[p]);
-      }
-    });
-
-    test('add/remove primary action keys', () => {
-      element.primaryActionKeys = [];
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
-      changeActions.removePrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('baz');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, []);
-    });
-
-    test('action buttons', () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const handler = sinon.spy();
-      changeActions.addTapListener(key, handler);
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      assert(handler.calledOnce);
-      changeActions.removeTapListener(key, handler);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      assert(handler.calledOnce);
-      changeActions.remove(key);
-      flush();
-      assert.isNull(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-    });
-
-    test('action button properties', async () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush();
-      const button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isOk(button);
-      assert.equal(button.getAttribute('data-label'), 'Bork!');
-      assert.isNotOk(button.disabled);
-      changeActions.setLabel(key, 'Yo');
-      changeActions.setTitle(key, 'Yo hint');
-      changeActions.setEnabled(key, false);
-      changeActions.setIcon(key, 'pupper');
-      await flush();
-      assert.equal(button.getAttribute('data-label'), 'Yo');
-      assert.equal(button.parentElement.getAttribute('title'), 'Yo hint');
-      assert.isTrue(button.disabled);
-      assert.equal(button.querySelector('iron-icon').icon,
-          'gr-icons:pupper');
-    });
-
-    test('hide action buttons', async () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      await flush();
-      let button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isOk(button);
-      assert.isFalse(button.hasAttribute('hidden'));
-      changeActions.setActionHidden(
-          changeActions.ActionType.REVISION, key, true);
-      flush();
-      button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isNotOk(button);
-    });
-
-    test('move action button to overflow', async () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      await flush();
-      assert.isTrue(element.$.moreActions.hidden);
-      assert.isOk(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      changeActions.setActionOverflow(
-          changeActions.ActionType.REVISION, key, true);
-      await flush();
-      assert.isNotOk(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      assert.isFalse(element.$.moreActions.hidden);
-      assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
-    });
-
-    test('change actions priority', () => {
-      const key1 =
-        changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const key2 =
-        changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
-      flush();
-      let buttons =
-        element.root.querySelectorAll('[data-action-key]');
-      assert.equal(buttons[0].getAttribute('data-action-key'), key1);
-      assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-      changeActions.setActionPriority(
-          changeActions.ActionType.REVISION, key1, 10);
-      flush();
-      buttons =
-        element.root.querySelectorAll('[data-action-key]');
-      assert.equal(buttons[0].getAttribute('data-action-key'), key2);
-      assert.equal(buttons[1].getAttribute('data-action-key'), key1);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
new file mode 100644
index 0000000..4dfc638
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -0,0 +1,209 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../../change/gr-change-actions/gr-change-actions';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  resetPlugins,
+} from '../../../test/test-utils';
+import {getPluginLoader} from './gr-plugin-loader';
+import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {PluginApi} from '../../../api/plugin';
+import {
+  ActionType,
+  ChangeActionsPluginApi,
+  PrimaryActionKey,
+} from '../../../api/change-actions';
+import {GrButton} from '../gr-button/gr-button';
+import {IronIconElement} from '@polymer/iron-icon';
+import {ChangeViewChangeInfo} from '../../../types/common';
+import {GrDropdown} from '../gr-dropdown/gr-dropdown';
+
+suite('gr-change-actions-js-api-interface tests', () => {
+  let element: GrChangeActions;
+  let changeActions: ChangeActionsPluginApi;
+  let plugin: PluginApi;
+
+  suite('early init', () => {
+    setup(async () => {
+      resetPlugins();
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      // Mimic all plugins loaded.
+      getPluginLoader().loadPlugins([]);
+      changeActions = plugin.changeActions();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
+    });
+
+    teardown(() => {
+      resetPlugins();
+    });
+
+    test('does not throw', () => {
+      assert.doesNotThrow(() => {
+        changeActions.add(ActionType.CHANGE, 'foo');
+      });
+    });
+  });
+
+  suite('normal init', () => {
+    setup(async () => {
+      resetPlugins();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
+      element.change = {} as ChangeViewChangeInfo;
+      element._hasKnownChainState = false;
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      changeActions = plugin.changeActions();
+      // Mimic all plugins loaded.
+      getPluginLoader().loadPlugins([]);
+    });
+
+    teardown(() => {
+      resetPlugins();
+    });
+
+    test('property existence', () => {
+      const properties = ['ActionType', 'ChangeActions', 'RevisionActions'];
+      for (const p of properties) {
+        // Have to type as any to prevent 'has no index signature.'
+        assert.deepEqual((changeActions as any)[p], (element as any)[p]);
+      }
+    });
+
+    test('add/remove primary action keys', () => {
+      element.primaryActionKeys = [];
+      changeActions.addPrimaryActionKey('foo' as PrimaryActionKey);
+      assert.deepEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('bar' as PrimaryActionKey);
+      assert.deepEqual(element.primaryActionKeys, ['foo', 'bar']);
+      changeActions.removePrimaryActionKey('foo');
+      assert.deepEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('baz');
+      assert.deepEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('bar');
+      assert.deepEqual(element.primaryActionKeys, []);
+    });
+
+    test('action buttons', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      const handler = sinon.spy();
+      changeActions.addTapListener(key, handler);
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+      await element.updateComplete;
+      assert(handler.calledOnce);
+      changeActions.removeTapListener(key, handler);
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+      await element.updateComplete;
+      assert(handler.calledOnce);
+      changeActions.remove(key);
+      await element.updateComplete;
+      assert.isUndefined(
+        query<GrButton>(element, `[data-action-key="${key}"]`)
+      );
+    });
+
+    test('action button properties', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(
+        element,
+        `[data-action-key="${key}"]`
+      );
+      assert.isOk(button);
+      assert.equal(button.getAttribute('data-label'), 'Bork!');
+      assert.isNotOk(button.disabled);
+      changeActions.setLabel(key, 'Yo');
+      changeActions.setTitle(key, 'Yo hint');
+      changeActions.setEnabled(key, false);
+      changeActions.setIcon(key, 'pupper');
+      await element.updateComplete;
+      assert.equal(button.getAttribute('data-label'), 'Yo');
+      assert.equal(button.parentElement!.getAttribute('title'), 'Yo hint');
+      assert.isTrue(button.disabled);
+      assert.equal(
+        queryAndAssert<IronIconElement>(button, 'iron-icon').icon,
+        'gr-icons:pupper'
+      );
+    });
+
+    test('hide action buttons', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      await element.updateComplete;
+      let button = query<GrButton>(element, `[data-action-key="${key}"]`);
+      assert.isOk(button);
+      assert.isFalse(button!.hasAttribute('hidden'));
+      changeActions.setActionHidden(ActionType.REVISION, key, true);
+      await element.updateComplete;
+      button = query<GrButton>(element, `[data-action-key="${key}"]`);
+      assert.isNotOk(button);
+    });
+
+    test('move action button to overflow', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      await element.updateComplete;
+      assert.isTrue(queryAndAssert<GrDropdown>(element, '#moreActions').hidden);
+      assert.isOk(
+        queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
+      );
+      changeActions.setActionOverflow(ActionType.REVISION, key, true);
+      await element.updateComplete;
+      assert.isNotOk(query<GrButton>(element, `[data-action-key="${key}"]`));
+      assert.isFalse(
+        queryAndAssert<GrDropdown>(element, '#moreActions').hidden
+      );
+      assert.strictEqual(
+        queryAndAssert<GrDropdown>(element, '#moreActions').items![0].name,
+        'Bork!'
+      );
+    });
+
+    test('change actions priority', async () => {
+      const key1 = changeActions.add(ActionType.REVISION, 'Bork!');
+      const key2 = changeActions.add(ActionType.CHANGE, 'Squanch?');
+      await element.updateComplete;
+      let buttons = queryAll<GrButton>(element, '[data-action-key]');
+      assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+      assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+      changeActions.setActionPriority(ActionType.REVISION, key1, 10);
+      await element.updateComplete;
+      buttons = queryAll<GrButton>(element, '[data-action-key]');
+      assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+      assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 07fad80..0cce83c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -294,7 +294,7 @@
 
   off(eventName: string, cb: EventCallback) {
     this.reportingService.trackApi(fakeApi, 'global', 'off');
-    return this.eventEmitter.off(eventName, cb);
+    this.eventEmitter.off(eventName, cb);
   }
 
   on(eventName: string, cb: EventCallback) {
@@ -309,11 +309,11 @@
 
   removeAllListeners(eventName: string) {
     this.reportingService.trackApi(fakeApi, 'global', 'removeAllListeners');
-    return this.eventEmitter.removeAllListeners(eventName);
+    this.eventEmitter.removeAllListeners(eventName);
   }
 
   removeListener(eventName: string, cb: EventCallback) {
     this.reportingService.trackApi(fakeApi, 'global', 'removeListener');
-    return this.eventEmitter.removeListener(eventName, cb);
+    this.eventEmitter.removeListener(eventName, cb);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
deleted file mode 100644
index d53c266..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {getPluginLoader} from './gr-plugin-loader.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {_testOnly_getGerritInternalPluginApi} from './gr-gerrit.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {getAppContext} from '../../../services/app-context.js';
-
-suite('gr-gerrit tests', () => {
-  let element;
-  let clock;
-  let pluginApi;
-
-  setup(() => {
-    clock = sinon.useFakeTimers();
-
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
-    stubRestApi('send').returns(Promise.resolve({status: 200}));
-    element = getAppContext().jsApiService;
-    pluginApi = _testOnly_getGerritInternalPluginApi();
-  });
-
-  teardown(() => {
-    clock.restore();
-    element._removeEventCallbacks();
-    resetPlugins();
-  });
-
-  suite('proxy methods', () => {
-    test('Gerrit._isPluginEnabled proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon.stub(
-          getPluginLoader(),
-          'isPluginEnabled')
-          .callsFake((...args) => stubFn(...args)
-          );
-      pluginApi._isPluginEnabled('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-
-    test('Gerrit._isPluginLoaded proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon.stub(
-          getPluginLoader(),
-          'isPluginLoaded')
-          .callsFake((...args) => stubFn(...args));
-      pluginApi._isPluginLoaded('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts
new file mode 100644
index 0000000..fbd5107
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {getPluginLoader} from './gr-plugin-loader';
+import {resetPlugins} from '../../../test/test-utils';
+import {
+  GerritInternal,
+  _testOnly_getGerritInternalPluginApi,
+} from './gr-gerrit';
+import {stubRestApi} from '../../../test/test-utils';
+import {getAppContext} from '../../../services/app-context';
+import {GrJsApiInterface} from './gr-js-api-interface-element';
+import {SinonFakeTimers} from 'sinon';
+import {Timestamp} from '../../../api/rest-api';
+
+suite('gr-gerrit tests', () => {
+  let element: GrJsApiInterface;
+  let clock: SinonFakeTimers;
+  let pluginApi: GerritInternal;
+
+  setup(() => {
+    clock = sinon.useFakeTimers();
+
+    stubRestApi('getAccount').returns(
+      Promise.resolve({name: 'Judy Hopps', registered_on: '' as Timestamp})
+    );
+    stubRestApi('send').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+    element = getAppContext().jsApiService as GrJsApiInterface;
+    pluginApi = _testOnly_getGerritInternalPluginApi();
+  });
+
+  teardown(() => {
+    clock.restore();
+    element._removeEventCallbacks();
+    resetPlugins();
+  });
+
+  suite('proxy methods', () => {
+    test('Gerrit._isPluginEnabled proxy to getPluginLoader()', () => {
+      const stubFn = sinon.stub();
+      sinon
+        .stub(getPluginLoader(), 'isPluginEnabled')
+        .callsFake((...args) => stubFn(...args));
+      pluginApi._isPluginEnabled('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+
+    test('Gerrit._isPluginLoaded proxy to getPluginLoader()', () => {
+      const stubFn = sinon.stub();
+      sinon
+        .stub(getPluginLoader(), 'isPluginLoaded')
+        .callsFake((...args) => stubFn(...args));
+      pluginApi._isPluginLoaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index e7354c5..3fba6f5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -18,7 +18,7 @@
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
-  LabelNameToValuesMap,
+  LabelNameToValueMap,
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
@@ -361,7 +361,7 @@
         if (hasOwnProperty(r, 'labels')) {
           review = r as ReviewInput;
         } else {
-          review = {labels: r as LabelNameToValuesMap};
+          review = {labels: r as LabelNameToValueMap};
         }
       } catch (err: unknown) {
         this.reporting.error(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 38b4aee..bb0287a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -72,7 +72,7 @@
   _getOrCreateModuleInfo(plugin: PluginApi, opts: Options): ModuleInfo {
     const {endpoint, slot, type, moduleName, domHook} = opts;
     const existingModule = this._endpoints
-      .get(endpoint!)!
+      .get(endpoint)!
       .find(
         (info: ModuleInfo) =>
           info.plugin === plugin &&
@@ -91,7 +91,7 @@
         domHook,
         slot,
       };
-      this._endpoints.get(endpoint!)!.push(newModule);
+      this._endpoints.get(endpoint)!.push(newModule);
       return newModule;
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 5d4f119..52022b7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -26,7 +26,6 @@
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {ShowAlertEventDetail} from '../../../types/events';
 import {fireAlert} from '../../../utils/event-util';
 
 enum PluginState {
@@ -235,13 +234,7 @@
 
   _failToLoad(message: string, pluginUrl?: string) {
     // Show an alert with the error
-    document.dispatchEvent(
-      new CustomEvent<ShowAlertEventDetail>('show-alert', {
-        detail: {
-          message: `Plugin install error: ${message} from ${pluginUrl}`,
-        },
-      })
-    );
+    fireAlert(document, `Plugin install error: ${message} from ${pluginUrl}`);
     if (pluginUrl) this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
     this._checkIfCompleted();
   }
@@ -260,7 +253,7 @@
         plugin: null,
       });
     }
-    console.info(`Plugin ${key} ${state}`);
+    console.debug(`Plugin ${key} ${state}`);
     return this._plugins.get(key)!;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
similarity index 62%
rename from polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
rename to polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
index e097858..4bfa0eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -15,26 +15,34 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
-import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
-import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
-import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
+import {PluginLoader, _testOnly_resetPluginLoader} from './gr-plugin-loader';
+import {resetPlugins, stubBaseUrl} from '../../../test/test-utils';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils';
+import {PluginApi} from '../../../api/plugin';
+import {SinonFakeTimers} from 'sinon';
+import {Timestamp} from '../../../api/rest-api';
 
 suite('gr-plugin-loader tests', () => {
-  let plugin;
+  let plugin: PluginApi;
 
-  let url;
-  let pluginLoader;
-  let clock;
+  let url: string;
+  let pluginLoader: PluginLoader;
+  let clock: SinonFakeTimers;
+  let bodyStub: sinon.SinonStub;
 
   setup(() => {
     clock = sinon.useFakeTimers();
 
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
-    stubRestApi('send').returns(Promise.resolve({status: 200}));
+    stubRestApi('getAccount').returns(
+      Promise.resolve({name: 'Judy Hopps', registered_on: '' as Timestamp})
+    );
+    stubRestApi('send').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
     pluginLoader = _testOnly_resetPluginLoader();
-    sinon.stub(document.body, 'appendChild');
+    bodyStub = sinon.stub(document.body, 'appendChild');
     url = window.location.origin;
   });
 
@@ -44,12 +52,22 @@
   });
 
   test('reuse plugin for install calls', () => {
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
 
     let otherPlugin;
-    window.Gerrit.install(p => { otherPlugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
+    window.Gerrit.install(
+      p => {
+        otherPlugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
     assert.strictEqual(plugin, otherPlugin);
   });
 
@@ -60,10 +78,12 @@
   });
 
   test('report pluginsLoaded', async () => {
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
+    const pluginsLoadedStub = sinon.stub(
+      pluginLoader._getReporting(),
+      'pluginsLoaded'
+    );
     pluginsLoadedStub.reset();
-    window.Gerrit._loadPlugins([]);
+    (window.Gerrit as any)._loadPlugins([]);
     await flush();
     assert.isTrue(pluginsLoadedStub.called);
   });
@@ -85,11 +105,13 @@
   });
 
   test('plugins installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(() => void 0, undefined, url);
     });
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
+    const pluginsLoadedStub = sinon.stub(
+      pluginLoader._getReporting(),
+      'pluginsLoaded'
+    );
 
     const plugins = [
       'http://test.com/plugins/foo/static/test.js',
@@ -103,7 +125,7 @@
   });
 
   test('isPluginEnabled and isPluginLoaded', () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(() => void 0, undefined, url);
     });
 
@@ -114,14 +136,12 @@
     ];
     pluginLoader.loadPlugins(plugins);
     assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+      plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
     );
 
     flush();
     assert.isTrue(pluginLoader.arePluginsLoaded());
-    assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginLoaded(plugin))
-    );
+    assert.isTrue(plugins.every(plugin => pluginLoader.isPluginLoaded(plugin)));
   });
 
   test('plugins installed mixed result, 1 fail 1 succeed', async () => {
@@ -133,16 +153,22 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      window.Gerrit.install(() => {
-        if (url === plugins[0]) {
-          throw new Error('failed');
-        }
-      }, undefined, url);
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
+      window.Gerrit.install(
+        () => {
+          if (url === plugins[0]) {
+            throw new Error('failed');
+          }
+        },
+        undefined,
+        url
+      );
     });
 
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
+    const pluginsLoadedStub = sinon.stub(
+      pluginLoader._getReporting(),
+      'pluginsLoaded'
+    );
 
     pluginLoader.loadPlugins(plugins);
 
@@ -161,20 +187,26 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      window.Gerrit.install(() => {
-        if (url === plugins[0]) {
-          throw new Error('failed');
-        }
-      }, undefined, url);
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
+      window.Gerrit.install(
+        () => {
+          if (url === plugins[0]) {
+            throw new Error('failed');
+          }
+        },
+        undefined,
+        url
+      );
     });
 
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
+    const pluginsLoadedStub = sinon.stub(
+      pluginLoader._getReporting(),
+      'pluginsLoaded'
+    );
 
     pluginLoader.loadPlugins(plugins);
     assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+      plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
     );
 
     await flush();
@@ -194,14 +226,20 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      window.Gerrit.install(() => {
-        throw new Error('failed');
-      }, undefined, url);
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
+      window.Gerrit.install(
+        () => {
+          throw new Error('failed');
+        },
+        undefined,
+        url
+      );
     });
 
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
+    const pluginsLoadedStub = sinon.stub(
+      pluginLoader._getReporting(),
+      'pluginsLoaded'
+    );
 
     pluginLoader.loadPlugins(plugins);
 
@@ -220,13 +258,14 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      window.Gerrit.install(() => {
-      }, url === plugins[0] ? '' : 'alpha', url);
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
+      window.Gerrit.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
     });
 
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
+    const pluginsLoadedStub = sinon.stub(
+      pluginLoader._getReporting(),
+      'pluginsLoaded'
+    );
 
     pluginLoader.loadPlugins(plugins);
 
@@ -237,11 +276,13 @@
   });
 
   test('multiple assets for same plugin installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(() => void 0, undefined, url);
     });
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
+    const pluginsLoadedStub = sinon.stub(
+      pluginLoader._getReporting(),
+      'pluginsLoaded'
+    );
 
     const plugins = [
       'http://test.com/plugins/foo/static/test.js',
@@ -256,12 +297,14 @@
   });
 
   suite('plugin path and url', () => {
-    let loadJsPluginStub;
+    let loadJsPluginStub: sinon.SinonStub;
     setup(() => {
       loadJsPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
-        loadJsPluginStub(url);
-      });
+      sinon
+        .stub(pluginLoader, '_createScriptTag')
+        .callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
+          loadJsPluginStub(url)
+        );
     });
 
     test('invalid plugin path', () => {
@@ -270,62 +313,56 @@
         failToLoadStub(...args);
       });
 
-      pluginLoader.loadPlugins([
-        'foo/bar',
-      ]);
+      pluginLoader.loadPlugins(['foo/bar']);
 
       assert.isTrue(failToLoadStub.calledOnce);
-      assert.isTrue(failToLoadStub.calledWithExactly(
+      assert.isTrue(
+        failToLoadStub.calledWithExactly(
           'Unrecognized plugin path foo/bar',
           'foo/bar'
-      ));
+        )
+      );
     });
 
     test('relative path for plugins', () => {
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-      ]);
+      pluginLoader.loadPlugins(['foo/bar.js']);
 
       assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
-      );
+      assert.isTrue(loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`));
     });
 
     test('relative path should honor getBaseUrl', () => {
       const testUrl = '/test';
       stubBaseUrl(testUrl);
 
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-      ]);
+      pluginLoader.loadPlugins(['foo/bar.js']);
 
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
+        loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
       );
     });
 
     test('absolute path for plugins', () => {
-      pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.js',
-      ]);
+      pluginLoader.loadPlugins(['http://e.com/foo/bar.js']);
 
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
+        loadJsPluginStub.calledWithExactly('http://e.com/foo/bar.js')
       );
     });
   });
 
   suite('With ASSETS_PATH', () => {
-    let loadJsPluginStub;
+    let loadJsPluginStub: sinon.SinonStub;
     setup(() => {
       window.ASSETS_PATH = 'https://cdn.com';
       loadJsPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
-        loadJsPluginStub(url);
-      });
+      sinon
+        .stub(pluginLoader, '_createScriptTag')
+        .callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
+          loadJsPluginStub(url)
+        );
     });
 
     teardown(() => {
@@ -333,34 +370,31 @@
     });
 
     test('Should try load plugins from assets path instead', () => {
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-      ]);
+      pluginLoader.loadPlugins(['foo/bar.js']);
 
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+        loadJsPluginStub.calledWithExactly('https://cdn.com/foo/bar.js')
+      );
     });
 
     test('Should honor original path if exists', () => {
-      pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.js',
-      ]);
+      pluginLoader.loadPlugins(['http://e.com/foo/bar.js']);
 
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
+        loadJsPluginStub.calledWithExactly('http://e.com/foo/bar.js')
+      );
     });
 
     test('Should try replace current host with assetsPath', () => {
       const host = window.location.origin;
-      pluginLoader.loadPlugins([
-        `${host}/foo/bar.js`,
-      ]);
+      pluginLoader.loadPlugins([`${host}/foo/bar.js`]);
 
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+        loadJsPluginStub.calledWithExactly('https://cdn.com/foo/bar.js')
+      );
     });
   });
 
@@ -369,22 +403,19 @@
       'http://e.com/foo/bar.js',
       'http://e.com/bar/foo.js',
     ]);
-    assert.isTrue(document.body.appendChild.calledTwice);
+    assert.isTrue(bodyStub.calledTwice);
   });
 
   test('can call awaitPluginsLoaded multiple times', async () => {
-    const plugins = [
-      'http://e.com/foo/bar.js',
-      'http://e.com/bar/foo.js',
-    ];
+    const plugins = ['http://e.com/foo/bar.js', 'http://e.com/bar/foo.js'];
 
     let installed = false;
-    function pluginCallback(url) {
+    function pluginCallback(url: string) {
       if (url === plugins[1]) {
         installed = true;
       }
     }
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(() => pluginCallback(url), undefined, url);
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 30f91b8..2ece82c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -99,6 +99,9 @@
   }
 
   registerStyleModule(endpoint: string, moduleName: string) {
+    console.warn(
+      `The deprecated plugin API 'registerStyleModule()' was called with parameters '${endpoint}' and '${moduleName}'.`
+    );
     this.report.trackApi(this, 'plugin', 'registerStyleModule');
     getPluginEndpoints().registerModule(this, {
       endpoint,
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 6026688..ee3bec7 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -18,12 +18,9 @@
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
 import '../gr-vote-chip/gr-vote-chip';
-import '../gr-account-label/gr-account-label';
-import '../gr-account-link/gr-account-link';
 import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../gr-label/gr-label';
 import '../gr-tooltip-content/gr-tooltip-content';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {
@@ -31,9 +28,7 @@
   LabelInfo,
   ApprovalInfo,
   AccountId,
-  isQuickLabelInfo,
   isDetailedLabelInfo,
-  LabelNameToInfoMap,
 } from '../../../types/common';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
@@ -41,10 +36,8 @@
 import {
   canVote,
   getApprovalInfo,
-  getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
-  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
@@ -62,19 +55,6 @@
   }
 }
 
-enum LabelClassName {
-  NEGATIVE = 'negative',
-  POSITIVE = 'positive',
-  MIN = 'min',
-  MAX = 'max',
-}
-
-interface FormattedLabel {
-  className?: LabelClassName;
-  account: ApprovalInfo | AccountInfo;
-  value: string;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends LitElement {
   @property({type: Object})
@@ -108,8 +88,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -119,9 +97,6 @@
       fontStyles,
       votingStyles,
       css`
-        .placeholder {
-          color: var(--deemphasized-text-color);
-        }
         .hidden {
           display: none;
         }
@@ -133,33 +108,6 @@
           margin-right: var(--spacing-s);
           padding: 1px;
         }
-        .max {
-          background-color: var(--vote-color-approved);
-        }
-        .min {
-          background-color: var(--vote-color-rejected);
-        }
-        .positive {
-          background-color: var(--vote-color-recommended);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-recommended);
-          color: var(--chip-color);
-        }
-        .negative {
-          background-color: var(--vote-color-disliked);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-disliked);
-          color: var(--chip-color);
-        }
-        .hidden {
-          display: none;
-        }
-        td {
-          vertical-align: top;
-        }
-        tr {
-          min-height: var(--line-height-normal);
-        }
         gr-tooltip-content {
           display: block;
         }
@@ -174,17 +122,10 @@
         gr-button[disabled] iron-icon {
           color: var(--border-color);
         }
-        gr-account-link {
-          --account-max-length: 100px;
-          margin-right: var(--spacing-xs);
-        }
         iron-icon {
           height: calc(var(--line-height-normal) - 2px);
           width: calc(var(--line-height-normal) - 2px);
         }
-        .labelValueContainer:not(:first-of-type) td {
-          padding-top: var(--spacing-s);
-        }
         .reviewer-row {
           padding-top: var(--spacing-s);
         }
@@ -209,14 +150,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderNewSubmitRequirements() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
     const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
@@ -239,23 +172,6 @@
     </div>`;
   }
 
-  private renderOldSubmitRequirements() {
-    const labelInfo = this.labelInfo;
-    return html` <p
-        class="placeholder ${this.computeShowPlaceholder(
-          labelInfo,
-          this.change?.labels
-        )}"
-      >
-        No votes
-      </p>
-      <table>
-        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
-          mappedLabel => this.renderLabel(mappedLabel)
-        )}
-      </table>`;
-  }
-
   renderReviewerVote(reviewer: AccountInfo) {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
@@ -268,15 +184,15 @@
         hasNeutralStatus(labelInfo, approvalInfo));
     return html`<div class="reviewer-row">
       <gr-account-chip
-        .account="${reviewer}"
-        .change="${this.change}"
-        .vote="${approvalInfo}"
-        .label="${labelInfo}"
+        .account=${reviewer}
+        .change=${this.change}
+        .vote=${approvalInfo}
+        .label=${labelInfo}
       >
         <gr-vote-chip
           slot="vote-chip"
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
+          .vote=${approvalInfo}
+          .label=${labelInfo}
           circle-shape
         ></gr-vote-chip
       ></gr-account-chip>
@@ -286,29 +202,6 @@
     </div>`;
   }
 
-  renderLabel(mappedLabel: FormattedLabel) {
-    const {labelInfo, change} = this;
-    return html` <tr class="labelValueContainer">
-      <td>
-        <gr-tooltip-content
-          has-tooltip
-          title="${this._computeValueTooltip(labelInfo, mappedLabel.value)}"
-        >
-          <gr-label class="${mappedLabel.className} voteChip font-small">
-            ${mappedLabel.value}
-          </gr-label>
-        </gr-tooltip-content>
-      </td>
-      <td>
-        <gr-account-link
-          .account="${mappedLabel.account}"
-          .change="${change}"
-        ></gr-account-link>
-      </td>
-      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
-    </tr>`;
-  }
-
   private renderVoteAbility(reviewer: AccountInfo) {
     if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
       const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
@@ -327,10 +220,8 @@
       <gr-button
         link
         aria-label="Remove vote"
-        @click="${this.onDeleteVote}"
-        data-account-id="${ifDefined(
-          reviewer._account_id as number | undefined
-        )}"
+        @click=${this.onDeleteVote}
+        data-account-id=${ifDefined(reviewer._account_id as number | undefined)}
         class="deleteBtn ${this.computeDeleteClass(
           reviewer,
           this.mutable,
@@ -343,83 +234,6 @@
   }
 
   /**
-   * This method also listens on change.labels.*,
-   * to trigger computation when a label is removed from the change.
-   *
-   * The third parameter is just for *triggering* computation.
-   */
-  private mapLabelInfo(
-    labelInfo?: LabelInfo,
-    account?: AccountInfo,
-    _?: LabelNameToInfoMap
-  ): FormattedLabel[] {
-    const result: FormattedLabel[] = [];
-    if (!labelInfo) {
-      return result;
-    }
-    if (!isDetailedLabelInfo(labelInfo)) {
-      if (
-        isQuickLabelInfo(labelInfo) &&
-        (labelInfo.rejected || labelInfo.approved)
-      ) {
-        const ok = labelInfo.approved || !labelInfo.rejected;
-        return [
-          {
-            value: ok ? '👍️' : '👎️',
-            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            // executed only if approved or rejected is not undefined
-            account: ok ? labelInfo.approved! : labelInfo.rejected!,
-          },
-        ];
-      }
-      return result;
-    }
-
-    // Sort votes by positivity.
-    // TODO(TS): maybe mark value as required if always present
-    const votes = (labelInfo.all || []).sort(
-      (a, b) => (a.value || 0) - (b.value || 0)
-    );
-    const votingRange = getVotingRangeOrDefault(labelInfo);
-    for (const label of votes) {
-      if (
-        label.value &&
-        (!isQuickLabelInfo(labelInfo) ||
-          label.value !== labelInfo.default_value)
-      ) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          if (label.value === votingRange.max) {
-            labelClassName = LabelClassName.MAX;
-          } else {
-            labelClassName = LabelClassName.POSITIVE;
-          }
-        } else if (label.value < 0) {
-          if (label.value === votingRange.min) {
-            labelClassName = LabelClassName.MIN;
-          } else {
-            labelClassName = LabelClassName.NEGATIVE;
-          }
-        }
-        const formattedLabel: FormattedLabel = {
-          value: `${labelValPrefix}${label.value}`,
-          className: labelClassName,
-          account: label,
-        };
-        if (label._account_id === account?._account_id) {
-          // Put self-votes at the top.
-          result.unshift(formattedLabel);
-        } else {
-          result.push(formattedLabel);
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
    * A user is able to delete a vote iff the mutable property is true and the
    * reviewer that left the vote exists in the list of removable_reviewers
    * received from the backend.
@@ -490,39 +304,4 @@
     }
     return labelInfo.values[score];
   }
-
-  /**
-   * This method also listens change.labels.* in
-   * order to trigger computation when a label is removed from the change.
-   *
-   * The second parameter is just for *triggering* computation.
-   */
-  private computeShowPlaceholder(
-    labelInfo?: LabelInfo,
-    _?: LabelNameToInfoMap
-  ) {
-    if (!labelInfo) {
-      return '';
-    }
-    if (
-      !isDetailedLabelInfo(labelInfo) &&
-      isQuickLabelInfo(labelInfo) &&
-      (labelInfo.rejected || labelInfo.approved)
-    ) {
-      return 'hidden';
-    }
-
-    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
-      for (const label of labelInfo.all) {
-        if (
-          label.value &&
-          (!isQuickLabelInfo(labelInfo) ||
-            label.value !== labelInfo.default_value)
-        ) {
-          return 'hidden';
-        }
-      }
-    }
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index cad1f69..f1336b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -20,20 +20,18 @@
 import {
   isHidden,
   mockPromise,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrLabelInfo} from './gr-label-info';
 import {GrButton} from '../gr-button/gr-button';
-import {GrLabel} from '../gr-label/gr-label';
-import {GrAccountLink} from '../gr-account-link/gr-account-link';
 import {
   createAccountWithIdNameAndEmail,
+  createDetailedLabelInfo,
   createParsedChange,
 } from '../../../test/test-data-generators';
-import {LabelInfo} from '../../../types/common';
+import {ApprovalInfo, LabelInfo} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
@@ -41,12 +39,51 @@
   let element: GrLabelInfo;
   const account = createAccountWithIdNameAndEmail(5);
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
 
     // Needed to trigger computed bindings.
     element.account = {};
-    element.change = {...createParsedChange(), labels: {}};
+    element.change = {
+      ...createParsedChange(),
+      labels: {},
+      reviewers: {
+        REVIEWER: [account],
+        CC: [],
+      },
+    };
+    const approval: ApprovalInfo = {
+      value: 2,
+      _account_id: account._account_id,
+    };
+    element.labelInfo = {
+      ...createDetailedLabelInfo(),
+      all: [approval],
+    };
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div>
+      <div class="reviewer-row">
+        <gr-account-chip>
+          <gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip>
+        </gr-account-chip>
+        <gr-tooltip-content has-tooltip="" title="Remove vote">
+          <gr-button
+            aria-disabled="false"
+            aria-label="Remove vote"
+            class="deleteBtn hidden"
+            data-account-id="5"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <iron-icon icon="gr-icons:delete"> </iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      </div>
+    </div>`);
   });
 
   suite('remove reviewer votes', () => {
@@ -62,6 +99,10 @@
       element.change = {
         ...createParsedChange(),
         labels: {'Code-Review': label},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [],
+        },
       };
       element.labelInfo = label;
       element.label = 'Code-Review';
@@ -108,101 +149,6 @@
     });
   });
 
-  suite('label color and order', () => {
-    test('valueless label rejected', async () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', async () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': "Don't submit as-is",
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', async () => {
-      const otherAccount = createAccountWithIdNameAndEmail(8);
-      element.account = account;
-      element.labelInfo = {
-        all: [
-          {...otherAccount, value: 1},
-          {...account, value: -1},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const chips = queryAll<GrAccountLink>(element, 'gr-account-link');
-      assert.equal(chips[0].account!._account_id, element.account._account_id);
-    });
-  });
-
   test('_computeValueTooltip', () => {
     // Existing label.
     let labelInfo: LabelInfo = {values: {0: 'Baz'}};
@@ -218,49 +164,4 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
-
-  test('placeholder', async () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [], values};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
deleted file mode 100644
index 842b35e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @fileoverview Consider removing this element as
- * its functionality seems to be duplicated with gr-tooltip and only
- * used in gr-label-info.
- */
-
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-label': GrLabel;
-  }
-}
-
-@customElement('gr-label')
-export class GrLabel extends LitElement {
-  static override get styles() {
-    return [];
-  }
-
-  override render() {
-    return html` <slot></slot> `;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
index 753835d..df37497 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
@@ -89,12 +89,12 @@
           <gr-autocomplete
             id="autocomplete"
             threshold="0"
-            .query="${this.query}"
-            ?disabled="${this.disabled}"
-            .placeholder="${this.placeholder}"
+            .query=${this.query}
+            ?disabled=${this.disabled}
+            .placeholder=${this.placeholder}
             borderless=""
           ></gr-autocomplete>
-          <div id="trigger" @click="${this._handleTriggerClick}">▼</div>
+          <div id="trigger" @click=${this._handleTriggerClick}>▼</div>
         </div>
       </div>
     `;
@@ -102,7 +102,7 @@
 
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('text')) {
-      fire(this, 'text-changed', this.text);
+      fire(this, 'text-changed', {value: this.text});
     }
   }
 
@@ -126,9 +126,6 @@
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'text-changed': CustomEvent<string>;
-  }
   interface HTMLElementTagNameMap {
     'gr-labeled-autocomplete': GrLabeledAutocomplete;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
similarity index 82%
rename from polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
rename to polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
index e83698f..fe5b1b8 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
@@ -15,24 +15,25 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-lib-loader.js';
-import {GrLibLoader} from './gr-lib-loader.js';
+import '../../../test/common-test-setup-karma';
+import './gr-lib-loader';
+import {GrLibLoader} from './gr-lib-loader';
 
 suite('gr-lib-loader tests', () => {
-  let grLibLoader;
-  let resolveLoad;
-  let rejectLoad;
-  let loadStub;
+  let grLibLoader: GrLibLoader;
+  let resolveLoad: any;
+  let rejectLoad: any;
+  let loadStub: sinon.SinonStub;
 
   setup(() => {
     grLibLoader = new GrLibLoader();
 
-    loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(() =>
-      new Promise((resolve, reject) => {
-        resolveLoad = resolve;
-        rejectLoad = reject;
-      })
+    loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(
+      () =>
+        new Promise((resolve, reject) => {
+          resolveLoad = resolve;
+          rejectLoad = reject;
+        })
     );
   });
 
@@ -109,7 +110,7 @@
 
     const libraryConfig = {
       src: 'foo.js',
-      configureCallback: () => window.library,
+      configureCallback: () => (window as any).library,
     };
 
     const loaded1 = sinon.stub();
@@ -118,7 +119,7 @@
     grLibLoader.getLibrary(libraryConfig).then(loaded1);
     grLibLoader.getLibrary(libraryConfig).then(loaded2);
 
-    window.library = library;
+    (window as any).library = library;
     resolveLoad();
     await flush();
 
@@ -135,19 +136,19 @@
 
   suite('preloaded', () => {
     setup(() => {
-      window.library = {
+      (window as any).library = {
         initialize: sinon.stub(),
       };
     });
 
     teardown(() => {
-      delete window.library;
+      delete (window as any).library;
     });
 
     test('does not load library again if detected present', async () => {
       const libraryConfig = {
         src: 'foo.js',
-        checkPresent: () => window.library !== undefined,
+        checkPresent: () => (window as any).library !== undefined,
       };
 
       const loaded1 = sinon.stub();
@@ -173,8 +174,8 @@
     test('runs configuration for externally loaded library', async () => {
       const libraryConfig = {
         src: 'foo.js',
-        checkPresent: () => window.library !== undefined,
-        configureCallback: () => window.library.initialize(),
+        checkPresent: () => (window as any).library !== undefined,
+        configureCallback: () => (window as any).library.initialize(),
       };
 
       grLibLoader.getLibrary(libraryConfig);
@@ -182,14 +183,14 @@
       resolveLoad();
       await flush();
 
-      assert.isTrue(window.library.initialize.calledOnce);
+      assert.isTrue((window as any).library.initialize.calledOnce);
     });
 
     test('loads library again if not detected present', async () => {
-      window.library = undefined;
+      (window as any).library = undefined;
       const libraryConfig = {
         src: 'foo.js',
-        checkPresent: () => window.library !== undefined,
+        checkPresent: () => (window as any).library !== undefined,
       };
 
       grLibLoader.getLibrary(libraryConfig);
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
deleted file mode 100644
index f63779c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 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 '../gr-js-api-interface/gr-js-api-interface';
-
-import {EventType} from '../../../api/plugin';
-import {getAppContext} from '../../../services/app-context';
-
-import {LibraryConfig} from './gr-lib-loader';
-
-export const HLJS_LIBRARY_CONFIG: LibraryConfig = {
-  // preloaded in PolyGerritIndexHtml.soy
-  src: 'bower_components/highlightjs/highlight.min.js',
-  checkPresent: () => window.hljs !== undefined,
-  configureCallback: () => {
-    window.hljs!.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
-    getAppContext().jsApiService.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
-      hljs: window.hljs,
-    });
-    return window.hljs;
-  },
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
index 872d01c..5fd75da 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
@@ -20,7 +20,7 @@
   src: 'bower_components/resemblejs/resemble.js',
   checkPresent: () => window.resemble !== undefined,
   configureCallback: () => {
-    window.resemble!.outputSettings({
+    window.resemble.outputSettings({
       errorColor: {red: 255, green: 0, blue: 255},
       errorType: 'flat',
       transparency: 0,
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index 9bb112e..6b5fc39 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {customElement, property} from 'lit/decorators';
-import {html, LitElement} from 'lit';
+import {css, html, LitElement} from 'lit';
 import '../gr-tooltip-content/gr-tooltip-content';
 
 declare global {
@@ -38,13 +38,19 @@
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit = 0;
+  limit = 25;
 
   @property({type: String})
   tooltip?: string;
 
   static override get styles() {
-    return [];
+    return [
+      css`
+        :host {
+          white-space: nowrap;
+        }
+      `,
+    ];
   }
 
   override render() {
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index 801b8bf..ad99406 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -113,10 +113,10 @@
           this.transparentBackground
         )}"
       >
-        <a href="${this.href}">
+        <a href=${this.href}>
           <gr-limited-text
-            .limit="${this.limit}"
-            .text="${this.text}"
+            .limit=${this.limit}
+            .text=${this.text}
           ></gr-limited-text>
         </a>
         <gr-button
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
index 36f518b..2bd6b6c 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -43,7 +43,6 @@
    * in the text as well as custom links if any are specified in the linkConfig
    * parameter.
    *
-   * @constructor
    * @param linkConfig Comment links as specified by the commentlinks field on a
    *     project config.
    * @param callback The callback to be fired when an intermediate parse result
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index ab5f1ad..9544891 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -192,9 +192,10 @@
       () => {
         if (!this.isConnected || !this.path) return;
         if (filter) {
-          return page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+          page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+          return;
         }
-        return page.show(this.path);
+        page.show(this.path);
       },
       REQUEST_DEBOUNCE_INTERVAL_MS
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index af0251f..4895674 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -42,6 +42,9 @@
 
 /**
  * @attr {Boolean} with-backdrop - inherited from IronOverlay
+ * @attr {Boolean} always-on-top - inherited from IronOverlay
+ * @attr {Boolean} no-cancel-on-esc-key - inherited from IronOverlay
+ * @attr {Boolean} no-cancel-on-outside-click - inherited from IronOverlay
  */
 @customElement('gr-overlay')
 export class GrOverlay extends base {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
similarity index 63%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
index f4099ec..ce2c72f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
@@ -15,20 +15,19 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import 'lodash/lodash.js';
-import {GrEtagDecorator} from './gr-etag-decorator.js';
+import '../../../test/common-test-setup-karma';
+import {GrEtagDecorator} from './gr-etag-decorator';
 
 suite('gr-etag-decorator', () => {
-  let etag;
+  let etag: GrEtagDecorator;
 
-  const fakeRequest = (opt_etag, opt_status) => {
+  const fakeRequest = (opt_etag?: string, opt_status?: number) => {
     const headers = new Headers();
     if (opt_etag) {
       headers.set('etag', opt_etag);
     }
     const status = opt_status || 200;
-    return {ok: true, status, headers};
+    return {...new Response(), ok: true, status, headers};
   };
 
   setup(() => {
@@ -40,35 +39,35 @@
   });
 
   test('works', () => {
-    etag.collect('/foo', fakeRequest('bar'));
+    etag.collect('/foo', fakeRequest('bar'), '');
     const options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+    assert.strictEqual(options!.headers!.get('If-None-Match'), 'bar');
   });
 
   test('updates etags', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    etag.collect('/foo', fakeRequest('baz'));
+    etag.collect('/foo', fakeRequest('bar'), '');
+    etag.collect('/foo', fakeRequest('baz'), '');
     const options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
+    assert.strictEqual(options!.headers!.get('If-None-Match'), 'baz');
   });
 
   test('discards empty etags', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    etag.collect('/foo', fakeRequest());
+    etag.collect('/foo', fakeRequest('bar'), '');
+    etag.collect('/foo', fakeRequest(), '');
     const options = etag.getOptions('/foo', {headers: new Headers()});
-    assert.isNull(options.headers.get('If-None-Match'));
+    assert.isNull(options!.headers!.get('If-None-Match'));
   });
 
   test('discards etags in order used', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    _.times(29, i => {
-      etag.collect('/qaz/' + i, fakeRequest('qaz'));
-    });
+    etag.collect('/foo', fakeRequest('bar'), '');
+    for (let i = 0; i < 29; i++) {
+      etag.collect(`/qaz/${i}`, fakeRequest('qaz'), '');
+    }
     let options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-    etag.collect('/zaq', fakeRequest('zaq'));
+    assert.strictEqual(options!.headers!.get('If-None-Match'), 'bar');
+    etag.collect('/zaq', fakeRequest('zaq'), '');
     options = etag.getOptions('/foo', {headers: new Headers()});
-    assert.isNull(options.headers.get('If-None-Match'));
+    assert.isNull(options!.headers!.get('If-None-Match'));
   });
 
   test('getCachedPayload', () => {
@@ -81,4 +80,3 @@
     assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 332e0c5..95f3eb1 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -304,7 +304,7 @@
     const elapsed = endTime - startTime;
     const startAt = new Date(startTime);
     const endAt = new Date(endTime);
-    console.info(
+    console.debug(
       [
         'HTTP',
         status,
@@ -516,7 +516,8 @@
       .catch(err => {
         fireNetworkError(err);
         if (req.errFn) {
-          return req.errFn.call(undefined, null, err);
+          req.errFn.call(undefined, null, err);
+          return;
         } else {
           throw err;
         }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 209cb5c..ef90764 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -112,7 +112,7 @@
       });
       assert.equal(writeScheduler.scheduled.length, 0);
       await assertReadRequest();
-      const res: Response = (await promise) as Response;
+      const res: Response = await promise;
       assert.equal(await res.text(), 'Yay');
     });
 
@@ -124,7 +124,7 @@
       });
       assert.equal(readScheduler.scheduled.length, 0);
       await assertWriteRequest();
-      const res: Response = (await promise) as Response;
+      const res: Response = await promise;
       assert.equal(await res.text(), 'Yay');
     });
   });
@@ -274,7 +274,7 @@
 
       // But we expect the result from the network to return a 429 error when
       // it's no longer being retried.
-      const res: Response = (await promise) as Response;
+      const res: Response = await promise;
       assert.equal(res.status, 429);
     });
 
@@ -306,7 +306,7 @@
       await flush();
       // We expect a retry.
       await assertReadRequest();
-      const res: Response = (await promise) as Response;
+      const res: Response = await promise;
       assert.equal(await res.text(), 'Yay');
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 6e1b20c..d352583 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -81,9 +81,9 @@
     return html` <label>${label}</label>
       <div class="commandContainer">
         <gr-copy-clipboard
-          .text="${this.command}"
+          .text=${this.command}
           hasTooltip
-          buttonTitle="${this.tooltip}"
+          buttonTitle=${this.tooltip}
         ></gr-copy-clipboard>
       </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index b602a87..e0c49c9 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -19,11 +19,7 @@
 import '../gr-overlay/gr-overlay';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-textarea_html';
 import {getAppContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {
   GrAutocompleteDropdown,
@@ -31,7 +27,13 @@
   ItemSelectedEvent,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {addShortcut, Key} from '../../../utils/dom-util';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {PropertyValues} from 'lit';
+import {classMap} from 'lit/directives/class-map';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -63,15 +65,6 @@
   match: string;
 }
 
-export interface GrTextarea {
-  $: {
-    textarea: IronAutogrowTextareaElement;
-    emojiSuggestions: GrAutocompleteDropdown;
-    caratSpan: HTMLSpanElement;
-    hiddenText: HTMLDivElement;
-  };
-}
-
 declare global {
   interface HTMLElementEventMap {
     'item-selected': CustomEvent<ItemSelectedEvent>;
@@ -79,62 +72,48 @@
 }
 
 @customElement('gr-textarea')
-export class GrTextarea extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrTextarea extends LitElement {
   /**
    * @event bind-value-changed
    */
-  @property({type: String})
-  autocomplete?: string;
+  @query('#textarea') textarea?: IronAutogrowTextareaElement;
 
-  @property({type: Boolean})
-  disabled?: boolean;
+  @query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
 
-  @property({type: Number})
-  rows?: number;
+  @query('#caratSpan', true) caratSpan?: HTMLSpanElement;
 
-  @property({type: Number})
-  maxRows?: number;
+  @query('#hiddenText') hiddenText?: HTMLDivElement;
 
-  @property({type: String})
-  placeholder?: string;
+  @property() autocomplete?: string;
 
-  @property({type: String, notify: true, observer: '_handleTextChanged'})
-  text = '';
+  @property({type: Boolean}) disabled?: boolean;
 
-  @property({type: Boolean})
-  hideBorder = false;
+  @property({type: Number}) rows?: number;
+
+  @property({type: Number}) maxRows?: number;
+
+  @property({type: String}) placeholder?: string;
+
+  @property({type: String}) text = '';
+
+  @property({type: Boolean, attribute: 'hide-border'}) hideBorder = false;
 
   /** Text input should be rendered in monospace font.  */
-  @property({type: Boolean})
-  monospace = false;
+  @property({type: Boolean}) monospace = false;
 
   /** Text input should be rendered in code font, which is smaller than the
     standard monospace font. */
-  @property({type: Boolean})
-  code = false;
+  @property({type: Boolean}) code = false;
 
-  @property({type: Number})
-  _colonIndex: number | null = null;
+  @state() colonIndex: number | null = null;
 
-  @property({type: String, observer: '_determineSuggestions'})
-  _currentSearchString?: string;
+  @state() currentSearchString?: string;
 
-  @property({type: Boolean})
-  _hideEmojiAutocomplete = true;
+  @state() hideEmojiAutocomplete = true;
 
-  @property({type: Number})
-  _index: number | null = null;
+  @state() private index: number | null = null;
 
-  @property({type: Array})
-  _suggestions: EmojiSuggestion[] = [];
-
-  @property({type: Number})
-  readonly _verticalOffset = 20;
-  // Offset makes dropdown appear below text.
+  @state() suggestions: EmojiSuggestion[] = [];
 
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
@@ -152,52 +131,139 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e), {
-        doNotPrevent: true,
-      })
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e), {
-        doNotPrevent: true,
-      })
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e), {
-        doNotPrevent: true,
-      })
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e), {
-        doNotPrevent: true,
-      })
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e), {
-        doNotPrevent: true,
-      })
-    );
-  }
-
-  override ready() {
-    super.ready();
     if (this.monospace) {
       this.classList.add('monospace');
     }
     if (this.code) {
       this.classList.add('code');
     }
-    if (this.hideBorder) {
-      this.$.textarea.classList.add('noBorder');
+    this.cleanups.push(
+      addShortcut(this, {key: Key.UP}, e => this.handleUpKey(e), {
+        doNotPrevent: true,
+      })
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.DOWN}, e => this.handleDownKey(e), {
+        doNotPrevent: true,
+      })
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.TAB}, e => this.handleTabKey(e), {
+        doNotPrevent: true,
+      })
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this.handleEnterByKey(e), {
+        doNotPrevent: true,
+      })
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, e => this.handleEscKey(e), {
+        doNotPrevent: true,
+      })
+    );
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: flex;
+        position: relative;
+      }
+      :host(.monospace) {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        font-weight: var(--font-weight-normal);
+      }
+      :host(.code) {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        /* usually 16px = 12px + 4px */
+        line-height: calc(var(--font-size-code) + var(--spacing-s));
+        font-weight: var(--font-weight-normal);
+      }
+      #emojiSuggestions {
+        font-family: var(--font-family);
+      }
+      gr-autocomplete {
+        display: inline-block;
+      }
+      #textarea {
+        background-color: var(--view-background-color);
+        width: 100%;
+      }
+      #hiddenText #emojiSuggestions {
+        visibility: visible;
+        white-space: normal;
+      }
+      iron-autogrow-textarea {
+        position: relative;
+      }
+      #textarea.noBorder {
+        border: none;
+      }
+      #hiddenText {
+        display: block;
+        float: left;
+        position: absolute;
+        visibility: hidden;
+        width: 100%;
+        white-space: pre-wrap;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <div id="hiddenText"></div>
+      <!-- When the autocomplete is open, the span is moved at the end of
+      hiddenText in order to correctly position the dropdown. After being moved,
+      it is set as the positionTarget for the emojiSuggestions dropdown. -->
+      <span id="caratSpan"></span>
+      <gr-autocomplete-dropdown
+        id="emojiSuggestions"
+        .suggestions=${this.suggestions}
+        .index=${this.index}
+        .verticalOffset=${20}
+        @dropdown-closed=${this.resetEmojiDropdown}
+        @item-selected=${this.handleEmojiSelect}
+      >
+      </gr-autocomplete-dropdown>
+      <iron-autogrow-textarea
+        id="textarea"
+        class=${classMap({noBorder: this.hideBorder})}
+        .autocomplete=${this.autocomplete}
+        .placeholder=${this.placeholder}
+        ?disabled=${this.disabled}
+        .rows=${this.rows}
+        .maxRows=${this.maxRows}
+        .value=${this.text}
+        @value-changed=${(e: ValueChangedEvent) => {
+          this.text = e.detail.value;
+        }}
+        @bind-value-changed=${this.onValueChanged}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('text')) {
+      this.handleTextChanged(this.text);
+    }
+    if (changedProperties.has('currentSearchString')) {
+      this.determineSuggestions(this.currentSearchString!);
     }
   }
 
+  // private but used in test
   closeDropdown() {
-    return this.$.emojiSuggestions.close();
+    this.emojiSuggestions?.close();
   }
 
   getNativeTextarea() {
-    return this.$.textarea.textarea;
+    return this.textarea!.textarea;
   }
 
   putCursorAtEnd() {
@@ -210,85 +276,87 @@
     });
   }
 
-  _handleEscKey(e: KeyboardEvent) {
-    if (this._hideEmojiAutocomplete) {
+  private handleEscKey(e: KeyboardEvent) {
+    if (this.hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this._resetEmojiDropdown();
+    this.resetEmojiDropdown();
   }
 
-  _handleUpKey(e: KeyboardEvent) {
-    if (this._hideEmojiAutocomplete) {
+  private handleUpKey(e: KeyboardEvent) {
+    if (this.hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.$.emojiSuggestions.cursorUp();
-    this.$.textarea.textarea.focus();
+    this.emojiSuggestions!.cursorUp();
+    this.textarea!.textarea.focus();
     this.disableEnterKeyForSelectingEmoji = false;
   }
 
-  _handleDownKey(e: KeyboardEvent) {
-    if (this._hideEmojiAutocomplete) {
+  private handleDownKey(e: KeyboardEvent) {
+    if (this.hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.$.emojiSuggestions.cursorDown();
-    this.$.textarea.textarea.focus();
+    this.emojiSuggestions!.cursorDown();
+    this.textarea!.textarea.focus();
     this.disableEnterKeyForSelectingEmoji = false;
   }
 
-  _handleTabKey(e: KeyboardEvent) {
+  private handleTabKey(e: KeyboardEvent) {
     // Tab should have normal behavior if the picker is closed or if the user
     // has only typed ':'.
-    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+    if (this.hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+    this.setEmoji(this.emojiSuggestions!.getCurrentText());
   }
 
-  _handleEnterByKey(e: KeyboardEvent) {
+  // private but used in test
+  handleEnterByKey(e: KeyboardEvent) {
     // Enter should have newline behavior if the picker is closed or if the user
     // has only typed ':'. Also make sure that shortcuts aren't clobbered.
-    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+    if (this.hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
       this.indent(e);
       return;
     }
 
     e.preventDefault();
     e.stopPropagation();
-    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+    this.setEmoji(this.emojiSuggestions!.getCurrentText());
   }
 
-  _handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
+  // private but used in test
+  handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
     if (e.detail.selected?.dataset['value']) {
-      this._setEmoji(e.detail.selected?.dataset['value']);
+      this.setEmoji(e.detail.selected?.dataset['value']);
     }
   }
 
-  _setEmoji(text: string) {
-    if (this._colonIndex === null) {
+  private setEmoji(text: string) {
+    if (this.colonIndex === null) {
       return;
     }
-    const colonIndex = this._colonIndex;
-    this.text = this._getText(text);
-    this.$.textarea.selectionStart = colonIndex + 1;
-    this.$.textarea.selectionEnd = colonIndex + 1;
+    const colonIndex = this.colonIndex;
+    this.text = this.getText(text);
+    this.textarea!.selectionStart = colonIndex + 1;
+    this.textarea!.selectionEnd = colonIndex + 1;
     this.reporting.reportInteraction('select-emoji', {type: text});
-    this._resetEmojiDropdown();
+    this.resetEmojiDropdown();
   }
 
-  _getText(value: string) {
+  private getText(value: string) {
     if (!this.text) return '';
     return (
-      this.text.substr(0, this._colonIndex || 0) +
+      this.text.substr(0, this.colonIndex || 0) +
       value +
-      this.text.substr(this.$.textarea.selectionStart)
+      this.text.substr(this.textarea!.selectionStart)
     );
   }
 
@@ -297,36 +365,31 @@
    * the text up until the point of interest. Then caratSpan element is added
    * to the end and is set to be the positionTarget for the dropdown. Together
    * this allows the dropdown to appear near where the user is typing.
+   * private but used in test
    */
-  _updateCaratPosition() {
-    this._hideEmojiAutocomplete = false;
-    if (typeof this.$.textarea.value === 'string') {
-      this.$.hiddenText.textContent = this.$.textarea.value.substr(
+  updateCaratPosition() {
+    this.hideEmojiAutocomplete = false;
+    if (typeof this.textarea!.value === 'string') {
+      this.hiddenText!.textContent = this.textarea!.value.substr(
         0,
-        this.$.textarea.selectionStart
+        this.textarea!.selectionStart
       );
     }
 
-    const caratSpan = this.$.caratSpan;
-    this.$.hiddenText.appendChild(caratSpan);
-    this.$.emojiSuggestions.positionTarget = caratSpan;
-    this._openEmojiDropdown();
+    const caratSpan = this.caratSpan!;
+    this.hiddenText!.appendChild(caratSpan);
+    this.emojiSuggestions!.positionTarget = caratSpan;
+    this.openEmojiDropdown();
   }
 
   /**
-   * _handleKeydown used for key handling in the this.$.textarea AND all child
+   * handleKeydown used for key handling in the this.textarea! AND all child
    * autocomplete options.
+   * private but used in test
    */
-  _onValueChanged(e: BindValueChangeEvent) {
+  onValueChanged(e: BindValueChangeEvent) {
     // Relay the event.
-    this.dispatchEvent(
-      new CustomEvent('bind-value-changed', {
-        detail: e,
-        composed: true,
-        bubbles: true,
-      })
-    );
-
+    fire(this, 'bind-value-changed', {value: e.detail.value});
     // If cursor is not in textarea (just opened with colon as last char),
     // Don't do anything.
     if (
@@ -338,9 +401,9 @@
 
     const charAtCursor =
       e.detail && e.detail.value
-        ? e.detail.value[this.$.textarea.selectionStart - 1]
+        ? e.detail.value[this.textarea!.selectionStart - 1]
         : '';
-    if (charAtCursor !== ':' && this._colonIndex === null) {
+    if (charAtCursor !== ':' && this.colonIndex === null) {
       return;
     }
 
@@ -348,88 +411,95 @@
     // colons after space or in beginning of textarea
     if (charAtCursor === ':') {
       if (
-        this.$.textarea.selectionStart < 2 ||
-        e.detail.value[this.$.textarea.selectionStart - 2] === ' '
+        this.textarea!.selectionStart < 2 ||
+        e.detail.value[this.textarea!.selectionStart - 2] === ' '
       ) {
-        this._colonIndex = this.$.textarea.selectionStart - 1;
+        this.colonIndex = this.textarea!.selectionStart - 1;
       }
     }
-    if (this._colonIndex === null) {
+    if (this.colonIndex === null) {
       return;
     }
 
-    this._currentSearchString = e.detail.value.substr(
-      this._colonIndex + 1,
-      this.$.textarea.selectionStart - this._colonIndex - 1
+    this.currentSearchString = e.detail.value.substr(
+      this.colonIndex + 1,
+      this.textarea!.selectionStart - this.colonIndex - 1
     );
+    this.determineSuggestions(this.currentSearchString);
     // Under the following conditions, close and reset the dropdown:
     // - The cursor is no longer at the end of the current search string
     // - The search string is an space or new line
     // - The colon has been removed
     // - There are no suggestions that match the search string
     if (
-      this.$.textarea.selectionStart !==
-        this._currentSearchString.length + this._colonIndex + 1 ||
-      this._currentSearchString === ' ' ||
-      this._currentSearchString === '\n' ||
-      !(e.detail.value[this._colonIndex] === ':') ||
-      !this._suggestions ||
-      !this._suggestions.length
+      this.textarea!.selectionStart !==
+        this.currentSearchString.length + this.colonIndex + 1 ||
+      this.currentSearchString === ' ' ||
+      this.currentSearchString === '\n' ||
+      !(e.detail.value[this.colonIndex] === ':') ||
+      !this.suggestions ||
+      !this.suggestions.length
     ) {
-      this._resetEmojiDropdown();
+      this.resetEmojiDropdown();
       // Otherwise open the dropdown and set the position to be just below the
       // cursor.
-    } else if (this.$.emojiSuggestions.isHidden) {
-      this._updateCaratPosition();
+    } else if (this.emojiSuggestions!.isHidden) {
+      this.updateCaratPosition();
     }
-    this.$.textarea.textarea.focus();
+    this.textarea!.textarea.focus();
   }
 
-  _openEmojiDropdown() {
-    this.$.emojiSuggestions.open();
+  private openEmojiDropdown() {
+    this.emojiSuggestions!.open();
     this.reporting.reportInteraction('open-emoji-dropdown');
   }
 
-  _formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
+  // private but used in test
+  formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
     const suggestions = [];
     for (const suggestion of matchedSuggestions) {
       suggestion.dataValue = suggestion.value;
       suggestion.text = `${suggestion.value} ${suggestion.match}`;
       suggestions.push(suggestion);
     }
-    this.set('_suggestions', suggestions);
+    this.suggestions = suggestions;
   }
 
-  _determineSuggestions(emojiText: string) {
+  // private but used in test
+  determineSuggestions(emojiText: string) {
     if (!emojiText.length) {
-      this._formatSuggestions(ALL_SUGGESTIONS);
+      this.formatSuggestions(ALL_SUGGESTIONS);
       this.disableEnterKeyForSelectingEmoji = true;
     } else {
       const matches = ALL_SUGGESTIONS.filter(suggestion =>
         suggestion.match.includes(emojiText)
       ).slice(0, MAX_ITEMS_DROPDOWN);
-      this._formatSuggestions(matches);
+      this.formatSuggestions(matches);
       this.disableEnterKeyForSelectingEmoji = false;
     }
   }
 
-  _resetEmojiDropdown() {
+  // private but used in test
+  resetEmojiDropdown() {
     // hide and reset the autocomplete dropdown.
-    flush();
-    this._currentSearchString = '';
-    this._hideEmojiAutocomplete = true;
+    this.requestUpdate();
+    this.currentSearchString = '';
+    this.hideEmojiAutocomplete = true;
     this.closeDropdown();
-    this._colonIndex = null;
-    this.$.textarea.textarea.focus();
+    this.colonIndex = null;
+    this.textarea!.textarea.focus();
   }
 
-  _handleTextChanged(text: string) {
+  private handleTextChanged(text: string) {
     // This is a bit redundant, because the `text` property has `notify:true`,
     // so whenever the `text` changes the component fires two identical events
     // `text-changed` and `value-changed`.
     this.dispatchEvent(
       new CustomEvent('value-changed', {detail: {value: text}})
     );
+    this.dispatchEvent(
+      new CustomEvent('text-changed', {detail: {value: text}})
+    );
   }
 
   private indent(e: KeyboardEvent): void {
@@ -439,8 +509,10 @@
     // When nothing is selected, selectionStart is the caret position. We want
     // the indentation level of the current line, not the end of the text which
     // may be different.
-    const currentLine = this.$.textarea.textarea.value
-      .substr(0, this.$.textarea.selectionStart)
+    const currentLine = this.textarea!.textarea.value.substr(
+      0,
+      this.textarea!.selectionStart
+    )
       .split('\n')
       .pop();
     const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
deleted file mode 100644
index d55481b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      position: relative;
-    }
-    :host(.monospace) {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      font-weight: var(--font-weight-normal);
-    }
-    :host(.code) {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      /* usually 16px = 12px + 4px */
-      line-height: calc(var(--font-size-code) + var(--spacing-s));
-      font-weight: var(--font-weight-normal);
-    }
-    #emojiSuggestions {
-      font-family: var(--font-family);
-    }
-    gr-autocomplete {
-      display: inline-block;
-    }
-    #textarea {
-      background-color: var(--view-background-color);
-      width: 100%;
-    }
-    #hiddenText #emojiSuggestions {
-      visibility: visible;
-      white-space: normal;
-    }
-    iron-autogrow-textarea {
-      position: relative;
-    }
-    #textarea.noBorder {
-      border: none;
-    }
-    #hiddenText {
-      display: block;
-      float: left;
-      position: absolute;
-      visibility: hidden;
-      width: 100%;
-      white-space: pre-wrap;
-    }
-  </style>
-  <div id="hiddenText"></div>
-  <!-- When the autocomplete is open, the span is moved at the end of
-      hiddenText in order to correctly position the dropdown. After being moved,
-      it is set as the positionTarget for the emojiSuggestions dropdown. -->
-  <span id="caratSpan"></span>
-  <gr-autocomplete-dropdown
-    vertical-align="top"
-    horizontal-align="left"
-    dynamic-align=""
-    id="emojiSuggestions"
-    suggestions="[[_suggestions]]"
-    index="[[_index]]"
-    vertical-offset="[[_verticalOffset]]"
-    on-dropdown-closed="_resetEmojiDropdown"
-    on-item-selected="_handleEmojiSelect"
-  >
-  </gr-autocomplete-dropdown>
-  <iron-autogrow-textarea
-    id="textarea"
-    autocomplete="[[autocomplete]]"
-    placeholder="[[placeholder]]"
-    disabled="[[disabled]]"
-    rows="[[rows]]"
-    max-rows="[[maxRows]]"
-    value="{{text}}"
-    on-bind-value-changed="_onValueChanged"
-  ></iron-autogrow-textarea>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 318c720..a3b5bf4 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -18,26 +18,31 @@
 import '../../../test/common-test-setup-karma';
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-
-const basicFixture = fixtureFromElement('gr-textarea');
-
-const monospaceFixture = fixtureFromTemplate(html`
-  <gr-textarea monospace="true"></gr-textarea>
-`);
-
-const hideBorderFixture = fixtureFromTemplate(html`
-  <gr-textarea hide-border="true"></gr-textarea>
-`);
+import {waitUntil} from '../../../test/test-utils';
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-textarea tests', () => {
   let element: GrTextarea;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrTextarea>(html`<gr-textarea></gr-textarea>`);
     sinon.stub(element.reporting, 'reportInteraction');
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div id="hiddenText"></div>
+      <span id="caratSpan"> </span>
+      <gr-autocomplete-dropdown
+        id="emojiSuggestions"
+        is-hidden=""
+        style="position: fixed; top: 150px; left: 392.5px; box-sizing: border-box; max-height: 300px; max-width: 785px;"
+      >
+      </gr-autocomplete-dropdown>
+      <iron-autogrow-textarea aria-disabled="false" id="textarea">
+      </iron-autogrow-textarea> `);
   });
 
   test('monospace is set properly', () => {
@@ -45,91 +50,103 @@
   });
 
   test('hideBorder is set properly', () => {
-    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+    assert.isFalse(element.textarea!.classList.contains('noBorder'));
   });
 
-  test('emoji selector is not open with the textarea lacks focus', () => {
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+  test('emoji selector is not open with the textarea lacks focus', async () => {
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     element.text = ':';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    await element.updateComplete;
+    assert.isFalse(!element.emojiSuggestions!.isHidden);
   });
 
-  test('emoji selector is not open when a general text is entered', () => {
-    MockInteractions.focus(element.$.textarea);
-    element.$.textarea.selectionStart = 9;
-    element.$.textarea.selectionEnd = 9;
+  test('emoji selector is not open when a general text is entered', async () => {
+    MockInteractions.focus(element.textarea!);
+    await waitUntil(() => element.textarea!.focused === true);
+    element.textarea!.selectionStart = 9;
+    element.textarea!.selectionEnd = 9;
     element.text = 'some text';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    await element.updateComplete;
+    assert.isFalse(!element.emojiSuggestions!.isHidden);
   });
 
-  test('emoji selector opens when a colon is typed & the textarea has focus', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector is open when a colon is typed & the textarea has focus', async () => {
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+    const listenerStub = sinon.stub();
+    element.addEventListener('bind-value-changed', listenerStub);
+    MockInteractions.focus(element.textarea!);
+    await waitUntil(() => element.textarea!.focused === true);
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     element.text = ':';
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 0);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, '');
+    await element.updateComplete;
+    assert.equal(listenerStub.lastCall.args[0].detail.value, ':');
+    assert.isTrue(element.textarea!.focused);
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.colonIndex, 0);
+    assert.isFalse(element.hideEmojiAutocomplete);
+    assert.equal(element.currentSearchString, '');
   });
 
-  test('emoji selector opens when a colon is typed after space', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector opens when a colon is typed after space', async () => {
+    MockInteractions.focus(element.textarea!);
+    await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 2;
-    element.$.textarea.selectionEnd = 2;
+    element.textarea!.selectionStart = 2;
+    element.textarea!.selectionEnd = 2;
     element.text = ' :';
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 1);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, '');
+    await element.updateComplete;
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.colonIndex, 1);
+    assert.isFalse(element.hideEmojiAutocomplete);
+    assert.equal(element.currentSearchString, '');
   });
 
-  test('emoji selector doesn`t open when a colon is typed after character', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector doesn`t open when a colon is typed after character', async () => {
+    MockInteractions.focus(element.textarea!);
+    await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 5;
-    element.$.textarea.selectionEnd = 5;
+    element.textarea!.selectionStart = 5;
+    element.textarea!.selectionEnd = 5;
     element.text = 'test:';
-    flush();
-    assert.isTrue(element.$.emojiSuggestions.isHidden);
-    assert.isTrue(element._hideEmojiAutocomplete);
+    await element.updateComplete;
+    assert.isTrue(element.emojiSuggestions!.isHidden);
+    assert.isTrue(element.hideEmojiAutocomplete);
   });
 
-  test('emoji selector opens when a colon is typed and some substring', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector opens when a colon is typed and some substring', async () => {
+    MockInteractions.focus(element.textarea!);
+    await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     element.text = ':';
-    element.$.textarea.selectionStart = 2;
-    element.$.textarea.selectionEnd = 2;
+    await element.updateComplete;
+    element.textarea!.selectionStart = 2;
+    element.textarea!.selectionEnd = 2;
     element.text = ':t';
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 0);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, 't');
+    await element.updateComplete;
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.colonIndex, 0);
+    assert.isFalse(element.hideEmojiAutocomplete);
+    assert.equal(element.currentSearchString, 't');
   });
 
-  test('emoji selector opens when a colon is typed in middle of text', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector opens when a colon is typed in middle of text', async () => {
+    MockInteractions.focus(element.textarea!);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 1
     const text = ': hello';
-    sinon.stub(element.$, 'textarea').value({
+    sinon.stub(element, 'textarea').value({
       selectionStart: 1,
       value: text,
       textarea: {
@@ -137,49 +154,55 @@
       },
     });
     element.text = text;
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 0);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, '');
+    await element.updateComplete;
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.colonIndex, 0);
+    assert.isFalse(element.hideEmojiAutocomplete);
+    assert.equal(element.currentSearchString, '');
   });
-  test('emoji selector closes when text changes before the colon', () => {
-    const resetStub = sinon.stub(element, '_resetEmojiDropdown');
-    MockInteractions.focus(element.$.textarea);
-    flush();
-    element.$.textarea.selectionStart = 10;
-    element.$.textarea.selectionEnd = 10;
-    element.text = 'test test ';
-    element.$.textarea.selectionStart = 12;
-    element.$.textarea.selectionEnd = 12;
-    element.text = 'test test :';
-    element.$.textarea.selectionStart = 15;
-    element.$.textarea.selectionEnd = 15;
-    element.text = 'test test :smi';
 
-    assert.equal(element._currentSearchString, 'smi');
+  test('emoji selector closes when text changes before the colon', async () => {
+    const resetStub = sinon.stub(element, 'resetEmojiDropdown');
+    MockInteractions.focus(element.textarea!);
+    await waitUntil(() => element.textarea!.focused === true);
+    await element.updateComplete;
+    element.textarea!.selectionStart = 10;
+    element.textarea!.selectionEnd = 10;
+    element.text = 'test test ';
+    await element.updateComplete;
+    element.textarea!.selectionStart = 12;
+    element.textarea!.selectionEnd = 12;
+    element.text = 'test test :';
+    await element.updateComplete;
+    element.textarea!.selectionStart = 15;
+    element.textarea!.selectionEnd = 15;
+    element.text = 'test test :smi';
+    await element.updateComplete;
+
+    assert.equal(element.currentSearchString, 'smi');
     assert.isFalse(resetStub.called);
     element.text = 'test test test :smi';
+    await element.updateComplete;
     assert.isTrue(resetStub.called);
   });
 
-  test('_resetEmojiDropdown', () => {
+  test('resetEmojiDropdown', async () => {
     const closeSpy = sinon.spy(element, 'closeDropdown');
-    element._resetEmojiDropdown();
-    assert.equal(element._currentSearchString, '');
-    assert.isTrue(element._hideEmojiAutocomplete);
-    assert.equal(element._colonIndex, null);
+    element.resetEmojiDropdown();
+    assert.equal(element.currentSearchString, '');
+    assert.isTrue(element.hideEmojiAutocomplete);
+    assert.equal(element.colonIndex, null);
 
-    element.$.emojiSuggestions.open();
-    flush();
-    element._resetEmojiDropdown();
+    element.emojiSuggestions!.open();
+    await element.updateComplete;
+    element.resetEmojiDropdown();
     assert.isTrue(closeSpy.called);
   });
 
-  test('_determineSuggestions', () => {
+  test('determineSuggestions', () => {
     const emojiText = 'tear';
-    const formatSpy = sinon.spy(element, '_formatSuggestions');
-    element._determineSuggestions(emojiText);
+    const formatSpy = sinon.spy(element, 'formatSuggestions');
+    element.determineSuggestions(emojiText);
     assert.isTrue(formatSpy.called);
     assert.isTrue(
       formatSpy.lastCall.calledWithExactly([
@@ -194,120 +217,124 @@
     );
   });
 
-  test('_formatSuggestions', () => {
+  test('formatSuggestions', () => {
     const matchedSuggestions = [
       {value: '😢', match: 'tear'},
       {value: '😂', match: 'tears'},
     ];
-    element._formatSuggestions(matchedSuggestions);
+    element.formatSuggestions(matchedSuggestions);
     assert.deepEqual(
       [
         {value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
         {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'},
       ],
-      element._suggestions
+      element.suggestions
     );
   });
 
-  test('_handleEmojiSelect', () => {
-    element.$.textarea.selectionStart = 16;
-    element.$.textarea.selectionEnd = 16;
+  test('handleEmojiSelect', async () => {
+    element.textarea!.selectionStart = 16;
+    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
-    element._colonIndex = 10;
+    element.colonIndex = 10;
+    await element.updateComplete;
     const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
     const event = new CustomEvent<ItemSelectedEvent>('item-selected', {
       detail: {trigger: 'click', selected: selectedItem},
     });
-    element._handleEmojiSelect(event);
+    element.handleEmojiSelect(event);
     assert.equal(element.text, 'test test 😂');
   });
 
-  test('_updateCaratPosition', () => {
-    element.$.textarea.selectionStart = 4;
-    element.$.textarea.selectionEnd = 4;
+  test('updateCaratPosition', async () => {
+    element.textarea!.selectionStart = 4;
+    element.textarea!.selectionEnd = 4;
     element.text = 'test';
-    element._updateCaratPosition();
+    await element.updateComplete;
+    element.updateCaratPosition();
     assert.deepEqual(
-      element.$.hiddenText.innerHTML,
-      element.text + element.$.caratSpan.outerHTML
+      element.hiddenText!.innerHTML,
+      element.text + element.caratSpan!.outerHTML
     );
   });
 
   test('newline receives matching indentation', async () => {
     const indentCommand = sinon.stub(document, 'execCommand');
-    element.$.textarea.value = '    a';
-    element._handleEnterByKey(
+    element.textarea!.value = '    a';
+    element.handleEnterByKey(
       new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13})
     );
-    await flush();
+    await element.updateComplete;
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
 
-  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
-    const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-    element.$.emojiSuggestions.dispatchEvent(
+  test('emoji dropdown is closed when iron-overlay-closed is fired', async () => {
+    const resetSpy = sinon.spy(element, 'closeDropdown');
+    element.emojiSuggestions!.dispatchEvent(
       new CustomEvent('dropdown-closed', {
         composed: true,
         bubbles: true,
       })
     );
+    await element.updateComplete;
     assert.isTrue(resetSpy.called);
   });
 
-  test('_onValueChanged fires bind-value-changed', () => {
+  test('onValueChanged fires bind-value-changed', () => {
     const listenerStub = sinon.stub();
     const eventObject = new CustomEvent('bind-value-changed', {
       detail: {currentTarget: {focused: false}, value: ''},
     });
     element.addEventListener('bind-value-changed', listenerStub);
-    element._onValueChanged(eventObject);
+    element.onValueChanged(eventObject);
     assert.isTrue(listenerStub.called);
   });
 
-  suite('keyboard shortcuts', () => {
-    function setupDropdown() {
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
+  suite('keyboard shortcuts', async () => {
+    async function setupDropdown() {
+      MockInteractions.focus(element.textarea!);
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
       element.text = ':';
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 2;
+      await element.updateComplete;
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 2;
       element.text = ':1';
-      flush();
+      await element.updateComplete;
     }
 
-    test('escape key', () => {
-      const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
+    test('escape key', async () => {
+      const resetSpy = sinon.spy(element, 'resetEmojiDropdown');
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         27,
         null,
         'Escape'
       );
       assert.isFalse(resetSpy.called);
-      setupDropdown();
+      await setupDropdown();
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         27,
         null,
         'Escape'
       );
       assert.isTrue(resetSpy.called);
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+      assert.isFalse(!element.emojiSuggestions!.isHidden);
     });
 
-    test('up key', () => {
-      const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
+    test('up key', async () => {
+      const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         38,
         null,
         'ArrowUp'
       );
       assert.isFalse(upSpy.called);
-      setupDropdown();
+      await setupDropdown();
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         38,
         null,
         'ArrowUp'
@@ -315,18 +342,18 @@
       assert.isTrue(upSpy.called);
     });
 
-    test('down key', () => {
-      const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
+    test('down key', async () => {
+      const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         40,
         null,
         'ArrowDown'
       );
       assert.isFalse(downSpy.called);
-      setupDropdown();
+      await setupDropdown();
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         40,
         null,
         'ArrowDown'
@@ -334,37 +361,37 @@
       assert.isTrue(downSpy.called);
     });
 
-    test('enter key', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
+    test('enter key', async () => {
+      const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         13,
         null,
         'Enter'
       );
       assert.isFalse(enterSpy.called);
-      setupDropdown();
+      await setupDropdown();
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
+        element.textarea!,
         13,
         null,
         'Enter'
       );
       assert.isTrue(enterSpy.called);
-      flush();
+      await element.updateComplete;
       assert.equal(element.text, '💯');
     });
 
-    test('enter key - ignored on just colon without more information', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+    test('enter key - ignored on just colon without more information', async () => {
+      const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.textarea!, 13);
       assert.isFalse(enterSpy.called);
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
+      MockInteractions.focus(element.textarea!);
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
       element.text = ':';
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      await element.updateComplete;
+      MockInteractions.pressAndReleaseKeyOn(element.textarea!, 13);
       assert.isFalse(enterSpy.called);
     });
   });
@@ -378,8 +405,11 @@
 
     let element: GrTextarea;
 
-    setup(() => {
-      element = monospaceFixture.instantiate() as GrTextarea;
+    setup(async () => {
+      element = await fixture<GrTextarea>(
+        html`<gr-textarea monospace></gr-textarea>`
+      );
+      await element.updateComplete;
     });
 
     test('monospace is set properly', () => {
@@ -396,12 +426,15 @@
 
     let element: GrTextarea;
 
-    setup(() => {
-      element = hideBorderFixture.instantiate() as GrTextarea;
+    setup(async () => {
+      element = await fixture<GrTextarea>(
+        html`<gr-textarea hide-border></gr-textarea>`
+      );
+      await element.updateComplete;
     });
 
     test('hideBorder is set properly', () => {
-      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+      assert.isTrue(element.textarea!.classList.contains('noBorder'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index 6ba69a5..aaf255a 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -168,7 +168,7 @@
     }
   }
 
-  _handleHideTooltip(e: Event | undefined) {
+  _handleHideTooltip(e?: Event) {
     if (this.isTouchDevice) {
       return;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
similarity index 72%
rename from polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
rename to polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
index d47c20b..de35c2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
@@ -15,21 +15,23 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-tooltip-content.js';
-
-const basicFixture = fixtureFromElement('gr-tooltip-content');
+import '../../../test/common-test-setup-karma';
+import './gr-tooltip-content';
+import {GrTooltipContent} from './gr-tooltip-content';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrTooltip} from '../gr-tooltip/gr-tooltip';
+import {query} from '../../../test/test-utils';
 
 suite('gr-tooltip-content tests', () => {
-  let element;
+  let element: GrTooltipContent;
 
-  function makeTooltip(tooltipRect, parentRect) {
+  function makeTooltip(tooltipRect: DOMRect, parentRect: DOMRect) {
     return {
       arrowCenterOffset: '0',
       getBoundingClientRect() {
         return tooltipRect;
       },
-      style: {left: 0, top: 0},
+      style: {left: '0', top: '0'},
       parentElement: {
         getBoundingClientRect() {
           return parentRect;
@@ -39,19 +41,21 @@
   }
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrTooltipContent>(html`
+      <gr-tooltip-content></gr-tooltip-content>
+    `);
     element.title = 'title';
     await element.updateComplete;
   });
 
   test('icon is not visible by default', () => {
-    assert.isNotOk(element.shadowRoot.querySelector('iron-icon'));
+    assert.isNotOk(query(element, 'iron-icon'));
   });
 
   test('icon is visible with showIcon property', async () => {
     element.showIcon = true;
     await element.updateComplete;
-    assert.isOk(element.shadowRoot.querySelector('iron-icon'));
+    assert.isOk(query(element, 'iron-icon'));
   });
 
   test('position-below attribute is reflected', async () => {
@@ -62,12 +66,13 @@
   });
 
   test('normal position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 100, width: 200};
-    });
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 100, width: 200} as DOMRect));
     const tooltip = makeTooltip(
-        {height: 30, width: 50},
-        {top: 0, left: 0, width: 1000});
+      {height: 30, width: 50} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
 
     element._positionTooltip(tooltip);
     assert.equal(tooltip.arrowCenterOffset, '0');
@@ -76,12 +81,13 @@
   });
 
   test('left side position', async () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 10, width: 50};
-    });
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 10, width: 50} as DOMRect));
     const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
 
     element._positionTooltip(tooltip);
     await element.updateComplete;
@@ -91,12 +97,13 @@
   });
 
   test('right side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50};
-    });
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 950, width: 50} as DOMRect));
     const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
 
     element._positionTooltip(tooltip);
     assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
@@ -105,12 +112,15 @@
   });
 
   test('position to bottom', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50, height: 50};
-    });
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(
+        () => ({top: 100, left: 950, width: 50, height: 50} as DOMRect)
+      );
     const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
 
     element.positionBelow = true;
     element._positionTooltip(tooltip);
@@ -171,4 +181,3 @@
     assert.isNotOk(element.tooltip);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
index 0e41891..bd4efcd 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -92,12 +92,12 @@
     return html` <div class="tooltip">
       <i
         class="arrowPositionBelow arrow"
-        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
       ></i>
       ${this.text}
       <i
         class="arrowPositionAbove arrow"
-        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
       ></i>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
index b693a9e..db25c52 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
@@ -26,7 +26,7 @@
   let element: GrTooltip;
 
   setup(async () => {
-    element = basicFixture.instantiate() as GrTooltip;
+    element = basicFixture.instantiate();
     await element.updateComplete;
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
index d89ed65..5859731 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -23,8 +23,6 @@
   isQuickLabelInfo,
   LabelInfo,
 } from '../../../api/rest-api';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   classForLabelStatus,
   getLabelStatus,
@@ -61,8 +59,6 @@
   @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
   tooltipWithWhoVoted = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -131,15 +127,12 @@
   }
 
   override render() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI))
-      return;
-
     const renderValue = this.renderValue();
     if (!renderValue) return;
 
     return html`<gr-tooltip-content
       class="container ${this.more ? 'more' : ''}"
-      title="${this.computeTooltip()}"
+      title=${this.computeTooltip()}
       has-tooltip
     >
       <div class="vote-chip ${this.computeClass()}">${renderValue}</div>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index f533bbd..b360dc0 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -22,7 +22,6 @@
 
 export class RevisionInfo {
   /**
-   * @constructor
    * @param change A change object resulting from a change detail
    *     call that includes revision information.
    */
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
deleted file mode 100644
index 7d0dd4f..0000000
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './revision-info.js';
-import {RevisionInfo} from './revision-info.js';
-suite('revision-info tests', () => {
-  let mockChange;
-
-  setup(() => {
-    mockChange = {
-      revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-        r2: {_number: 2, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p4'},
-        ]}},
-        r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
-        r4: {_number: 4, commit: {parents: [
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-        r5: {_number: 5, commit: {parents: [
-          {commit: 'p5'},
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-      },
-    };
-  });
-
-  test('getMaxParents', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.equal(ri.getMaxParents(), 3);
-  });
-
-  test('getParentCountMap', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
-  });
-
-  test('getParentCount', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1), 3);
-    assert.deepEqual(ri.getParentCount(3), 1);
-  });
-
-  test('getParentCount', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1), 3);
-    assert.deepEqual(ri.getParentCount(3), 1);
-  });
-
-  test('getParentId', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentId(1, 2), 'p3');
-    assert.deepEqual(ri.getParentId(2, 1), 'p4');
-    assert.deepEqual(ri.getParentId(3, 0), 'p5');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts
new file mode 100644
index 0000000..f862d72
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {
+  createChange,
+  createCommit,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {ChangeInfo, CommitId, PatchSetNum} from '../../../types/common';
+import './revision-info';
+import {RevisionInfo} from './revision-info';
+
+suite('revision-info tests', () => {
+  let mockChange: ChangeInfo;
+
+  setup(() => {
+    mockChange = {
+      ...createChange(),
+      revisions: {
+        r1: {
+          ...createRevision(),
+          _number: 1 as PatchSetNum,
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p1' as CommitId, subject: ''},
+              {commit: 'p2' as CommitId, subject: ''},
+              {commit: 'p3' as CommitId, subject: ''},
+            ],
+          },
+        },
+        r2: {
+          ...createRevision(),
+          _number: 2 as PatchSetNum,
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p1' as CommitId, subject: ''},
+              {commit: 'p4' as CommitId, subject: ''},
+            ],
+          },
+        },
+        r3: {
+          ...createRevision(),
+          _number: 3 as PatchSetNum,
+          commit: {
+            ...createCommit(),
+            parents: [{commit: 'p5' as CommitId, subject: ''}],
+          },
+        },
+        r4: {
+          ...createRevision(),
+          _number: 4 as PatchSetNum,
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p2' as CommitId, subject: ''},
+              {commit: 'p3' as CommitId, subject: ''},
+            ],
+          },
+        },
+        r5: {
+          ...createRevision(),
+          _number: 5 as PatchSetNum,
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p5' as CommitId, subject: ''},
+              {commit: 'p2' as CommitId, subject: ''},
+              {commit: 'p3' as CommitId, subject: ''},
+            ],
+          },
+        },
+      },
+    };
+  });
+
+  test('getMaxParents', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.equal(ri.getMaxParents(), 3);
+  });
+
+  test('getParentCountMap', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1 as PatchSetNum), 3);
+    assert.deepEqual(ri.getParentCount(3 as PatchSetNum), 1);
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1 as PatchSetNum), 3);
+    assert.deepEqual(ri.getParentCount(3 as PatchSetNum), 1);
+  });
+
+  test('getParentId', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentId(1 as PatchSetNum, 2), 'p3' as CommitId);
+    assert.deepEqual(ri.getParentId(2 as PatchSetNum, 1), 'p4' as CommitId);
+    assert.deepEqual(ri.getParentId(3 as PatchSetNum, 0), 'p5' as CommitId);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index 92cf968..47f5f81 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -200,8 +200,8 @@
     }
   `;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     this.setupButtonHoverHandler();
   }
 
@@ -220,16 +220,17 @@
   private setupButtonHoverHandler() {
     subscribe(
       this,
-      this.expandButtonsHover.pipe(
-        switchMap(e => {
-          if (e.eventType === 'leave') {
-            // cancel any previous delay
-            // for mouse enter
-            return EMPTY;
-          }
-          return of(e).pipe(delay(500));
-        })
-      ),
+      () =>
+        this.expandButtonsHover.pipe(
+          switchMap(e => {
+            if (e.eventType === 'leave') {
+              // cancel any previous delay
+              // for mouse enter
+              return EMPTY;
+            }
+            return of(e).pipe(delay(500));
+          })
+        ),
       ({buttonType, linesToExpand}) => {
         fire(this, 'diff-context-button-hovered', {
           buttonType,
@@ -334,11 +335,11 @@
     };
 
     const button = html` <paper-button
-      class="${classes}"
-      aria-label="${ariaLabel}"
-      @click="${expandHandler}"
-      @mouseenter="${() => mouseHandler('enter')}"
-      @mouseleave="${() => mouseHandler('leave')}"
+      class=${classes}
+      aria-label=${ariaLabel}
+      @click=${expandHandler}
+      @mouseenter=${() => mouseHandler('enter')}
+      @mouseleave=${() => mouseHandler('leave')}
     >
       <span class="showContext">${text}</span>
       ${tooltip}
@@ -451,7 +452,7 @@
 
     const position =
       buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
-    return html`<paper-tooltip offset="10" position="${position}"
+    return html`<paper-tooltip offset="10" position=${position}
       ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
     >`;
   }
@@ -461,8 +462,8 @@
     numLines: number,
     referenceLine: number
   ) {
-    assertIsDefined(this.diff, 'diff');
-    const syntaxTree = this.diff!.meta_b.syntax_tree;
+    if (!this.diff?.meta_b) return;
+    const syntaxTree = this.diff.meta_b.syntax_tree;
     const outlineSyntaxPath = findBlockTreePathForLine(
       referenceLine,
       syntaxTree
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index 7af40ed..d8d4e25 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -19,7 +19,6 @@
 import '../gr-diff/gr-diff-group';
 import './gr-context-controls';
 import {GrContextControls} from './gr-context-controls';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
@@ -373,7 +372,7 @@
       tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
       '20 common lines'
     );
-    assert.equal(tooltipAbove!.getAttribute('position'), 'top');
-    assert.equal(tooltipBelow!.getAttribute('position'), 'bottom');
+    assert.equal(tooltipAbove.getAttribute('position'), 'top');
+    assert.equal(tooltipBelow.getAttribute('position'), 'bottom');
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
index f121113..dae5c03 100644
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -1,29 +1,10 @@
 /**
  * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-coverage-layer_html';
+import {Side} from '../../../api/diff';
 import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
-import {customElement, property} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-coverage-layer': GrCoverageLayer;
-  }
-}
 
 const TOOLTIP_MAP = new Map([
   [CoverageType.COVERED, 'Covered by tests.'],
@@ -32,21 +13,12 @@
   [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
 ]);
 
-@customElement('gr-coverage-layer')
-export class GrCoverageLayer extends PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCoverageLayer implements DiffLayer {
   /**
    * Must be sorted by code_range.start_line.
    * Must only contain ranges that match the side.
    */
-  @property({type: Array})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: String})
-  side?: string;
+  private coverageRanges: CoverageRange[] = [];
 
   /**
    * We keep track of the line number from the previous annotate() call,
@@ -56,11 +28,22 @@
    * and efficient way for finding the coverage range that matches a given
    * line number.
    */
-  @property({type: Number})
-  _lineNumber = 0;
+  private lastLineNumber = 0;
 
-  @property({type: Number})
-  _index = 0;
+  /**
+   * See `lastLineNumber` comment.
+   */
+  private index = 0;
+
+  constructor(private readonly side: Side) {}
+
+  /**
+   * Must be sorted by code_range.start_line.
+   * Must only contain ranges that match the side.
+   */
+  setRanges(ranges: CoverageRange[]) {
+    this.coverageRanges = ranges;
+  }
 
   /**
    * Layer method to add annotations to a line.
@@ -87,27 +70,27 @@
     // If the line number is smaller than before, then we have to reset our
     // algorithm and start searching the coverage ranges from the beginning.
     // That happens for example when you expand diff sections.
-    if (elementLineNumber < this._lineNumber) {
-      this._index = 0;
+    if (elementLineNumber < this.lastLineNumber) {
+      this.index = 0;
     }
-    this._lineNumber = elementLineNumber;
+    this.lastLineNumber = elementLineNumber;
 
     // We simply loop through all the coverage ranges until we find one that
     // matches the line number.
-    while (this._index < this.coverageRanges.length) {
-      const coverageRange = this.coverageRanges[this._index];
+    while (this.index < this.coverageRanges.length) {
+      const coverageRange = this.coverageRanges[this.index];
 
       // If the line number has moved past the current coverage range, then
       // try the next coverage range.
-      if (this._lineNumber > coverageRange.code_range.end_line) {
-        this._index++;
+      if (this.lastLineNumber > coverageRange.code_range.end_line) {
+        this.index++;
         continue;
       }
 
       // If the line number has not reached the next coverage range (and the
       // range before also did not match), then this line has not been
       // instrumented. Nothing to do for this line.
-      if (this._lineNumber < coverageRange.code_range.start_line) {
+      if (this.lastLineNumber < coverageRange.code_range.start_line) {
         return;
       }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_html.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.js b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.js
deleted file mode 100644
index e886e61..0000000
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.js
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-line.js';
-import './gr-coverage-layer.js';
-
-const basicFixture = fixtureFromElement('gr-coverage-layer');
-
-suite('gr-coverage-layer', () => {
-  let element;
-
-  setup(() => {
-    const initialCoverageRanges = [
-      {
-        type: 'COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 1,
-          end_line: 2,
-        },
-      },
-      {
-        type: 'NOT_COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 3,
-          end_line: 4,
-        },
-      },
-      {
-        type: 'PARTIALLY_COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 5,
-          end_line: 6,
-        },
-      },
-      {
-        type: 'NOT_INSTRUMENTED',
-        side: 'right',
-        code_range: {
-          start_line: 8,
-          end_line: 9,
-        },
-      },
-    ];
-
-    element = basicFixture.instantiate();
-    element.coverageRanges = initialCoverageRanges;
-    element.side = 'right';
-  });
-
-  suite('annotate', () => {
-    function createLine(lineNumber) {
-      const lineEl = document.createElement('div');
-      lineEl.setAttribute('data-side', 'right');
-      lineEl.setAttribute('data-value', lineNumber);
-      lineEl.className = 'right';
-      return lineEl;
-    }
-
-    function checkLine(lineNumber, className, opt_negated) {
-      const line = createLine(lineNumber);
-      element.annotate(undefined, line, undefined);
-      let contains = line.classList.contains(className);
-      if (opt_negated) contains = !contains;
-      assert.isTrue(contains);
-    }
-
-    test('line 1-2 are covered', () => {
-      checkLine(1, 'COVERED');
-      checkLine(2, 'COVERED');
-    });
-
-    test('line 3-4 are not covered', () => {
-      checkLine(3, 'NOT_COVERED');
-      checkLine(4, 'NOT_COVERED');
-    });
-
-    test('line 5-6 are partially covered', () => {
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-    });
-
-    test('line 7 is implicitly not instrumented', () => {
-      checkLine(7, 'COVERED', true);
-      checkLine(7, 'NOT_COVERED', true);
-      checkLine(7, 'PARTIALLY_COVERED', true);
-      checkLine(7, 'NOT_INSTRUMENTED', true);
-    });
-
-    test('line 8-9 are not instrumented', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-    });
-
-    test('coverage correct, if annotate is called out of order', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(1, 'COVERED');
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(3, 'NOT_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-      checkLine(4, 'NOT_COVERED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-      checkLine(2, 'COVERED');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
new file mode 100644
index 0000000..5687b10
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {CoverageRange, CoverageType, Side} from '../../../api/diff';
+import {GrCoverageLayer} from './gr-coverage-layer';
+
+suite('gr-coverage-layer', () => {
+  let layer: GrCoverageLayer;
+
+  setup(() => {
+    const initialCoverageRanges: CoverageRange[] = [
+      {
+        type: CoverageType.COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: CoverageType.NOT_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+      {
+        type: CoverageType.PARTIALLY_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 5,
+          end_line: 6,
+        },
+      },
+      {
+        type: CoverageType.NOT_INSTRUMENTED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 8,
+          end_line: 9,
+        },
+      },
+    ];
+
+    layer = new GrCoverageLayer(Side.RIGHT);
+    layer.setRanges(initialCoverageRanges);
+  });
+
+  suite('annotate', () => {
+    function createLine(lineNumber: number) {
+      const lineEl = document.createElement('div');
+      lineEl.setAttribute('data-side', Side.RIGHT);
+      lineEl.setAttribute('data-value', lineNumber.toString());
+      lineEl.className = Side.RIGHT;
+      return lineEl;
+    }
+
+    function checkLine(
+      lineNumber: number,
+      className: string,
+      negated?: boolean
+    ) {
+      const content = document.createElement('div');
+      const line = createLine(lineNumber);
+      layer.annotate(content, line);
+      let contains = line.classList.contains(className);
+      if (negated) contains = !contains;
+      assert.isTrue(contains);
+    }
+
+    test('line 1-2 are covered', () => {
+      checkLine(1, 'COVERED');
+      checkLine(2, 'COVERED');
+    });
+
+    test('line 3-4 are not covered', () => {
+      checkLine(3, 'NOT_COVERED');
+      checkLine(4, 'NOT_COVERED');
+    });
+
+    test('line 5-6 are partially covered', () => {
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+    });
+
+    test('line 7 is implicitly not instrumented', () => {
+      checkLine(7, 'COVERED', true);
+      checkLine(7, 'NOT_COVERED', true);
+      checkLine(7, 'PARTIALLY_COVERED', true);
+      checkLine(7, 'NOT_INSTRUMENTED', true);
+    });
+
+    test('line 8-9 are not instrumented', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+    });
+
+    test('coverage correct, if annotate is called out of order', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(1, 'COVERED');
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(3, 'NOT_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+      checkLine(4, 'NOT_COVERED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+      checkLine(2, 'COVERED');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 6854ef34..d200c75 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -1,39 +1,24 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-coverage-layer/gr-coverage-layer';
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
-import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import './gr-diff-builder-side-by-side';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
 import {
   GrDiffProcessor,
+  GroupConsumer,
   KeyLocations,
 } from '../gr-diff-processor/gr-diff-processor';
 import {
@@ -41,7 +26,11 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {
+  DiffViewMode,
+  RenderPreferences,
+  RenderProgressEventDetail,
+} from '../../../api/diff';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {
@@ -49,24 +38,37 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {
+  fireAlert,
+  fire,
+  HTMLElementEventDetailType,
+} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {afterNextRender} from '../../../utils/dom-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
-export interface GrDiffBuilderElement {
-  $: {
-    processor: GrDiffProcessor;
-    rangeLayer: GrRangedCommentLayer;
-    coverageLayerLeft: GrCoverageLayer;
-    coverageLayerRight: GrCoverageLayer;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired whenever a new chunk of lines has been rendered synchronously - this
+     * only happens for full renders.
+     */
+    'render-progress': CustomEvent<RenderProgressEventDetail>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
+  }
 }
 
 export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
@@ -98,226 +100,184 @@
   }
 }
 
-@customElement('gr-diff-builder')
-export class GrDiffBuilderElement extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the diff begins rendering.
-   *
-   * @event render-start
-   */
-
-  /**
-   * Fired when the diff finishes rendering text content.
-   *
-   * @event render-content
-   */
-
-  @property({type: Object})
+// TODO: Rename the class and the file and remove "element". This is not an
+// element anymore.
+export class GrDiffBuilderElement implements GroupConsumer {
   diff?: DiffInfo;
 
-  @property({type: String})
+  diffElement?: HTMLTableElement;
+
   viewMode?: string;
 
-  @property({type: Boolean})
   isImageDiff?: boolean;
 
-  @property({type: Object})
   baseImage: ImageInfo | null = null;
 
-  @property({type: Object})
   revisionImage: ImageInfo | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
-  @property({type: String})
   path?: string;
 
-  @property({type: Object})
   prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
 
-  @property({type: Object})
   renderPrefs?: RenderPreferences;
 
-  @property({type: Object})
-  _builder?: DiffBuilder;
-
-  /**
-   * The gr-diff-processor adds (and only adds!) to this array. It does so by
-   * using `this.push()` and Polymer's two-way data binding.
-   * Below (@observe('_groups.splices')) we are observing the groups that the
-   * processor adds, and pass them on to the builder for rendering. Henceforth
-   * the builder groups are the source of truth, because when
-   * expanding/collapsing groups only the builder is updated. This field and the
-   * corresponsing one in the processor are not updated.
-   */
-  @property({type: Array})
-  _groups: GrDiffGroup[] = [];
+  useNewImageDiffUi = false;
 
   /**
    * Layers passed in from the outside.
+   *
+   * See `layersInternal` for where these layers will end up together with the
+   * internal layers.
    */
-  @property({type: Array})
   layers: DiffLayer[] = [];
 
+  // visible for testing
+  builder?: DiffBuilder;
+
   /**
-   * All layers, both from the outside and the default ones.
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
    */
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
 
-  @property({type: Boolean})
-  _showTabs?: boolean;
+  // visible for testing
+  showTabs?: boolean;
 
-  @property({type: Boolean})
-  _showTrailingWhitespace?: boolean;
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: Boolean})
-  useNewImageDiffUi = false;
-
-  @property({
-    type: Array,
-    computed: '_computeLeftCoverageRanges(coverageRanges)',
-  })
-  _leftCoverageRanges?: CoverageRange[];
-
-  @property({
-    type: Array,
-    computed: '_computeRightCoverageRanges(coverageRanges)',
-  })
-  _rightCoverageRanges?: CoverageRange[];
+  // visible for testing
+  showTrailingWhitespace?: boolean;
 
   /**
    * The promise last returned from `render()` while the asynchronous
    * rendering is running - `null` otherwise. Provides a `cancel()`
    * method that rejects it with `{isCancelled: true}`.
    */
-  @property({type: Object})
-  _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+
+  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+  private rangeLayer = new GrRangedCommentLayer();
+
+  // visible for testing
+  processor = new GrDiffProcessor();
 
   constructor() {
-    super();
-    afterNextRender(this, () => {
-      this.addEventListener(
-        'diff-context-expanded',
-        (e: CustomEvent<DiffContextExpandedEventDetail>) => {
-          // Don't stop propagation. The host may listen for reporting or
-          // resizing.
-          this.replaceGroup(e.detail.contextGroup, e.detail.groups);
-        }
-      );
-    });
+    this.processor.consumer = this;
   }
 
-  override disconnectedCallback() {
-    if (this._builder) {
-      this._builder.clear();
-    }
-    super.disconnectedCallback();
+  updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer.updateRanges(ranges);
   }
 
-  get diffElement(): HTMLTableElement {
-    // Not searching in shadowRoot, because the diff table is slotted!
-    return this.querySelector('#diffTable') as HTMLTableElement;
+  updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
   }
 
-  _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'left');
-  }
-
-  _computeRightCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'right');
-  }
-
-  render(keyLocations: KeyLocations) {
+  render(keyLocations: KeyLocations): void {
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
     // attached before plugins are installed.
-    this._setupAnnotationLayers();
+    this.setupAnnotationLayers();
 
-    this._showTabs = this.prefs.show_tabs;
-    this._showTrailingWhitespace = this.prefs.show_whitespace_errors;
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
 
     // Stop the processor if it's running.
     this.cancel();
 
-    if (this._builder) {
-      this._builder.clear();
-    }
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
-    this._builder = this._getDiffBuilder();
+    this.builder?.clear();
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+    this.builder = this.getDiffBuilder();
 
-    this.$.processor.context = this.prefs.context;
-    this.$.processor.keyLocations = keyLocations;
+    this.processor.context = this.prefs.context;
+    this.processor.keyLocations = keyLocations;
 
-    this._clearDiffContent();
-    this._builder.addColumns(
+    this.diffElement.addEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+
+    this.clearDiffContent();
+    this.builder.addColumns(
       this.diffElement,
       getLineNumberCellWidth(this.prefs)
     );
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    fireEvent(this, 'render-start');
-    this._cancelableRenderPromise = util.makeCancelable(
-      this.$.processor.process(this.diff.content, isBinary).then(() => {
-        if (this.isImageDiff) {
-          (this._builder as GrDiffBuilderImage).renderDiff();
-        }
-        fireEvent(this, 'render-content');
-      })
-    );
-    return (
-      this._cancelableRenderPromise
-        .finally(() => {
-          this._cancelableRenderPromise = null;
+    this.fireDiffEvent('render-start', {});
+    this.cancelableRenderPromise = makeCancelable(
+      this.processor
+        .process(this.diff.content, isBinary)
+        .then(() => {
+          if (this.isImageDiff) {
+            (this.builder as GrDiffBuilderImage).renderDiff();
+          }
+          afterNextRender(() => this.fireDiffEvent('render-content', {}));
         })
-        // Mocca testing does not like uncaught rejections, so we catch
+        // Mocha testing does not like uncaught rejections, so we catch
         // the cancels which are expected and should not throw errors in
         // tests.
         .catch(e => {
           if (!e.isCanceled) return Promise.reject(e);
           return;
         })
+        .finally(() => {
+          this.cancelableRenderPromise = null;
+        })
     );
   }
 
-  _setupAnnotationLayers() {
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  private fireDiffEvent<K extends keyof HTMLElementEventMap>(
+    type: K,
+    detail: HTMLElementEventDetailType<K>
+  ) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fire(this.diffElement, type, detail);
+  }
+
+  private fireDiffEventRenderProgress(detail: RenderProgressEventDetail) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fire(this.diffElement, 'render-progress', detail);
+  }
+
+  // visible for testing
+  setupAnnotationLayers() {
     const layers: DiffLayer[] = [
-      this._createTrailingWhitespaceLayer(),
-      this._createIntralineLayer(),
-      this._createTabIndicatorLayer(),
-      this._createSpecialCharacterIndicatorLayer(),
-      this.$.rangeLayer,
-      this.$.coverageLayerLeft,
-      this.$.coverageLayerRight,
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
+      this.rangeLayer,
+      this.coverageLayerLeft,
+      this.coverageLayerRight,
     ];
 
     if (this.layers) {
       layers.push(...this.layers);
     }
-    this._layers = layers;
+    this.layersInternal = layers;
   }
 
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this._builder) return null;
-    return this._builder.getContentTdByLine(lineNumber, side, root);
+    if (!this.builder) return null;
+    return this.builder.getContentTdByLine(lineNumber, side, root);
   }
 
-  _getDiffRowByChild(child: Element) {
+  private getDiffRowByChild(child: Element) {
     while (!child.classList.contains('diff-row') && child.parentElement) {
       child = child.parentElement;
     }
@@ -331,23 +291,23 @@
     const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
-    const row = this._getDiffRowByChild(lineEl);
+    const row = this.getDiffRowByChild(lineEl);
     return this.getContentTdByLine(line, side, row);
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this._builder) return null;
-    return this._builder.getLineElByNumber(lineNumber, side);
+    if (!this.builder) return null;
+    return this.builder.getLineElByNumber(lineNumber, side);
   }
 
   getLineNumberRows() {
-    if (!this._builder) return [];
-    return this._builder.getLineNumberRows();
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
   }
 
   getLineNumEls(side: Side) {
-    if (!this._builder) return [];
-    return this._builder.getLineNumEls(side);
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
   }
 
   /**
@@ -359,8 +319,8 @@
    * @param side The side the line number refer to.
    */
   unhideLine(lineNum: number, side: Side) {
-    if (!this._builder) return;
-    const group = this._builder.findGroup(side, lineNum);
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
     // Cannot unhide a line that is not part of the diff.
     if (!group) return;
     // If it's already visible, great!
@@ -388,8 +348,7 @@
         lineRange.end_line - lineRange.start_line + 1
       )
     );
-    this._builder.replaceGroup(group, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    this.replaceGroup(group, newGroups);
   }
 
   /**
@@ -404,37 +363,50 @@
     contextGroup: GrDiffGroup,
     newGroups: readonly GrDiffGroup[]
   ) {
-    if (!this._builder) return;
-    this._builder.replaceGroup(contextGroup, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    if (!this.builder) return;
+    this.fireDiffEvent('render-start', {});
+    const linesRendered = newGroups.reduce(
+      (sum, group) => sum + group.lines.length,
+      0
+    );
+    this.builder.replaceGroup(contextGroup, newGroups);
+    afterNextRender(() => {
+      this.fireDiffEvent('render-progress', {linesRendered});
+      this.fireDiffEvent('render-content', {});
+    });
   }
 
   cancel() {
-    this.$.processor.cancel();
-    if (this._cancelableRenderPromise) {
-      this._cancelableRenderPromise.cancel();
-      this._cancelableRenderPromise = null;
-    }
+    this.processor.cancel();
+    this.builder?.clear();
+    this.cancelableRenderPromise?.cancel();
+    this.cancelableRenderPromise = null;
+    this.diffElement?.removeEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
   }
 
-  _handlePreferenceError(pref: string): never {
+  // visible for testing
+  handlePreferenceError(pref: string): never {
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    fireAlert(this, message);
+    assertIsDefined(this.diffElement, 'diff table');
+    fireAlert(this.diffElement, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(): DiffBuilder {
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
+  // visible for testing
+  getDiffBuilder(): DiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
     if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
-      this._handlePreferenceError('tab size');
+      this.handlePreferenceError('tab size');
     }
 
     if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
-      this._handlePreferenceError('diff width');
+      this.handlePreferenceError('diff width');
     }
 
     const localPrefs = {...this.prefs};
@@ -463,7 +435,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
@@ -471,7 +443,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     }
@@ -481,36 +453,33 @@
     return builder;
   }
 
-  _clearDiffContent() {
+  private clearDiffContent() {
+    assertIsDefined(this.diffElement, 'diff table');
     this.diffElement.innerHTML = '';
   }
 
   /**
-   * Forward groups added by the processor to the builder for rendering.
+   * Called when the processor starts converting the diff information from the
+   * server into chunks.
    */
-  @observe('_groups.splices')
-  _groupsChanged(changeRecord: PolymerSpliceChange<GrDiffGroup[]>) {
-    if (!changeRecord || !this._builder) return;
-
-    // The processor either removes all groups or adds new ones to the end,
-    // so let's simplify the Polymer splices.
-    const isRemoval = changeRecord.indexSplices.find(
-      splice => splice.removed.length > 0
-    );
-    if (isRemoval) {
-      this._builder.clearGroups();
-      return;
-    }
-    for (const splice of changeRecord.indexSplices) {
-      const added = splice.object.slice(
-        splice.index,
-        splice.index + splice.addedCount
-      );
-      this._builder.addGroups(added);
-    }
+  clearGroups() {
+    if (!this.builder) return;
+    this.builder.clearGroups();
   }
 
-  _createIntralineLayer(): DiffLayer {
+  /**
+   * Called when the processor is done converting a chunk of the diff.
+   */
+  addGroup(group: GrDiffGroup) {
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    afterNextRender(() =>
+      this.fireDiffEventRenderProgress({linesRendered: group.lines.length})
+    );
+  }
+
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
     return {
       // Take a DIV.contentText element and a line object with intraline
       // differences to highlight and apply them to the element as
@@ -542,8 +511,9 @@
     };
   }
 
-  _createTabIndicatorLayer(): DiffLayer {
-    const show = () => this._showTabs;
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // If visible tabs are disabled, do nothing.
@@ -557,7 +527,7 @@
     };
   }
 
-  _createSpecialCharacterIndicatorLayer(): DiffLayer {
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // Find and annotate the locations of soft hyphen (\u00AD)
@@ -573,8 +543,9 @@
     };
   }
 
-  _createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this._showTrailingWhitespace;
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
 
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
@@ -602,18 +573,12 @@
   }
 
   setBlame(blame: BlameInfo[] | null) {
-    if (!this._builder) return;
-    this._builder.setBlame(blame ?? []);
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this._builder?.updateRenderPrefs(renderPrefs);
-    this.$.processor.updateRenderPrefs(renderPrefs);
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-builder': GrDiffBuilderElement;
+    this.builder?.updateRenderPrefs(renderPrefs);
+    this.processor.updateRenderPrefs(renderPrefs);
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
deleted file mode 100644
index 573f559..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-  <gr-ranged-comment-layer
-    id="rangeLayer"
-    comment-ranges="[[commentRanges]]"
-  ></gr-ranged-comment-layer>
-  <gr-coverage-layer
-    id="coverageLayerLeft"
-    coverage-ranges="[[_leftCoverageRanges]]"
-    side="left"
-  ></gr-coverage-layer>
-  <gr-coverage-layer
-    id="coverageLayerRight"
-    coverage-ranges="[[_rightCoverageRanges]]"
-    side="right"
-  ></gr-coverage-layer>
-  <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
deleted file mode 100644
index 8d3e8a7..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ /dev/null
@@ -1,1085 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-diff-builder-element.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {DiffViewMode, Side} from '../../../api/diff.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy.js';
-
-const basicFixture = fixtureFromTemplate(html`
-    <gr-diff-builder>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-const divWithTextFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-const mockDiffFixture = fixtureFromTemplate(html`
-<gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-// GrDiffBuilderElement forces these prefs to be set - tests that do not care
-// about these values can just set these defaults.
-const DEFAULT_PREFS = {
-  line_length: 10,
-  show_tabs: true,
-  tab_size: 4,
-};
-
-suite('gr-diff-builder tests', () => {
-  let prefs;
-  let element;
-  let builder;
-
-  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
-  const WBR_HTML = '<wbr class="style-scope gr-diff">';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    stubBaseUrl('/r');
-    prefs = {...DEFAULT_PREFS};
-    builder = new GrDiffBuilderLegacy({content: []}, prefs);
-  });
-
-  test('line_length applied with <wbr> if line_wrapping is true', () => {
-    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  test('line_length applied with line break if line_wrapping is false', () => {
-    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-      .forEach(mode => {
-        test(`line_length used for regular files under ${mode}`, () => {
-          element.path = '/a.txt';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 50);
-        });
-
-        test(`line_length ignored for commit msg under ${mode}`, () => {
-          element.path = '/COMMIT_MSG';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 72);
-        });
-      });
-
-  test('createTextEl linewrap with tabs', () => {
-    const text = '\t'.repeat(7) + '!';
-    const line = {text, highlights: []};
-    const el = builder.createTextEl(undefined, line);
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 2, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText > .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-        newlineEl);
-  });
-
-  test('_handlePreferenceError throws with invalid preference', () => {
-    element.prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder());
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    element.addEventListener('show-alert', errorStub);
-    assert.throws(() => element._handlePreferenceError('tab size'));
-    assert.equal(errorStub.lastCall.args[0].detail.message,
-        `The value of the 'tab size' user preference is invalid. ` +
-      `Fix in diff preferences`);
-  });
-
-  suite('intraline differences', () => {
-    let el;
-    let str;
-    let annotateElementSpy;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str, start, end) {
-      return Array.from(str).slice(start, end)
-          .join('');
-    }
-
-    setup(() => {
-      el = divWithTextFixture.instantiate();
-      str = el.textContent;
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-      layer = document.createElement('gr-diff-builder')
-          ._createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      const line = {
-        text: str,
-        highlights: [],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-          {startIndex: 18, endIndex: 22},
-        ],
-      };
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28},
-        ],
-      };
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28, endIndex: 28},
-        ],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTabs = true;
-      layer = element._createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTabs = false;
-
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let element;
-    let initialLayersCount;
-    let withLayerCount;
-    setup(() => {
-      const layers = [];
-      element = basicFixture.instantiate();
-      element.layers = layers;
-      element._showTrailingWhitespace = true;
-      element._setupAnnotationLayers();
-      initialLayersCount = element._layers.length;
-    });
-
-    test('no layers', () => {
-      element._setupAnnotationLayers();
-      assert.equal(element._layers.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers = [{}, {}];
-      setup(() => {
-        element = basicFixture.instantiate();
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        withLayerCount = element._layers.length;
-      });
-      test('with layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length,
-            withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTrailingWhitespace = true;
-      layer = element._createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let processStub;
-    let keyLocations;
-    let content;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.$.processor, 'process')
-          .returns(Promise.resolve());
-      keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-    });
-
-    test('text', () => {
-      element.diff = {content};
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isFalse(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('image', () => {
-      element.diff = {content, binary: true};
-      element.isImageDiff = true;
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('binary', () => {
-      element.diff = {content, binary: true};
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-  });
-
-  suite('rendering', () => {
-    let content;
-    let outputEl;
-    let keyLocations;
-
-    setup(async () => {
-      const prefs = {...DEFAULT_PREFS};
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      outputEl = element.querySelector('#diffTable');
-      keyLocations = {left: {}, right: {}};
-      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
-        const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
-        sinon.stub(builder, 'addColumns');
-        builder.buildSectionElement = function(group) {
-          const section = document.createElement('stub');
-          section.textContent = group.lines
-              .reduce((acc, line) => acc + line.text, '');
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {content};
-      element.prefs = prefs;
-      await element.render(keyLocations);
-    });
-
-    test('addColumns is called', () => {
-      assert.isTrue(element._builder.addColumns.called);
-    });
-
-    test('getGroupsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(3)');
-      const groups = element._builder.getGroupsByLineRange(1, 1, 'left');
-      assert.equal(groups.length, 1);
-      assert.strictEqual(groups[0].element, section);
-    });
-
-    test('getGroupsByLineRange over diff', () => {
-      const section = [
-        outputEl.querySelector('stub:nth-of-type(3)'),
-        outputEl.querySelector('stub:nth-of-type(4)'),
-      ];
-      const groups = element._builder.getGroupsByLineRange(1, 2, 'left');
-      assert.equal(groups.length, 2);
-      assert.strictEqual(groups[0].element, section[0]);
-      assert.strictEqual(groups[1].element, section[1]);
-    });
-
-    test('render-start and render-content are fired', async () => {
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-start');
-      assert.include(firedEventTypes, 'render-content');
-    });
-
-    test('cancel cancels the processor', () => {
-      const processorCancelStub = sinon.stub(element.$.processor, 'cancel');
-      element.cancel();
-      assert.isTrue(processorCancelStub.called);
-    });
-  });
-
-  suite('context hiding and expanding', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      const afterNextRenderPromise = new Promise((resolve, reject) => {
-        afterNextRender(element, resolve);
-      });
-      element.diff = {
-        content: [
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
-          {a: ['before'], b: ['after']},
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
-        ],
-      };
-      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
-      const keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: 1,
-      };
-      await element.render(keyLocations);
-      // Make sure all listeners are installed.
-      await afterNextRenderPromise;
-    });
-
-    test('hides lines behind two context controls', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      assert.equal(contextControls.length, 2);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 10');
-      assert.include(diffRows[3].textContent, 'before');
-      assert.include(diffRows[3].textContent, 'after');
-      assert.include(diffRows[4].textContent, 'unchanged 11');
-    });
-
-    test('clicking +x common lines expands those lines', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      const topExpandCommonButton = contextControls[0].shadowRoot
-          .querySelectorAll('.showContext')[0];
-      assert.include(topExpandCommonButton.textContent, '+9 common lines');
-      topExpandCommonButton.click();
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 6');
-      assert.include(diffRows[8].textContent, 'unchanged 7');
-      assert.include(diffRows[9].textContent, 'unchanged 8');
-      assert.include(diffRows[10].textContent, 'unchanged 9');
-      assert.include(diffRows[11].textContent, 'unchanged 10');
-      assert.include(diffRows[12].textContent, 'before');
-      assert.include(diffRows[12].textContent, 'after');
-      assert.include(diffRows[13].textContent, 'unchanged 11');
-    });
-
-    test('unhideLine shows the line with context', async () => {
-      const clock = sinon.useFakeTimers();
-      element.dispatchEvent.reset();
-      element.unhideLine(4, Side.LEFT);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
-      // Because context expanders do not hide <3 lines, lines 1-2 will also
-      // be shown.
-      // Lines 6-9 continue to be hidden
-      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 10');
-      assert.include(diffRows[8].textContent, 'before');
-      assert.include(diffRows[8].textContent, 'after');
-      assert.include(diffRows[9].textContent, 'unchanged 11');
-
-      clock.tick(1);
-      await flush();
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
-    });
-  });
-
-  suite('mock-diff', () => {
-    let element;
-    let builder;
-    let diff;
-    let keyLocations;
-
-    setup(async () => {
-      element = mockDiffFixture.instantiate();
-      diff = getMockDiffResponse();
-      element.diff = diff;
-
-      keyLocations = {left: {}, right: {}};
-
-      element.prefs = {
-        line_length: 80,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      await element.render(keyLocations);
-      builder = element._builder;
-    });
-
-    test('aria-labels on added line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.right')[5];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
-    });
-
-    test('aria-labels on removed line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.left')[10];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
-    });
-
-    test('getContentByLine', () => {
-      let actual;
-
-      actual = builder.getContentByLine(2, 'left');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(2, 'right');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(5, 'left');
-      assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-      actual = builder.getContentByLine(5, 'right');
-      assert.equal(actual.textContent, diff.content[1].b[0]);
-    });
-
-    test('getContentTdByLineEl works both with button and td', () => {
-      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
-
-      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
-      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
-      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
-      const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentTdRight = diffRow.querySelectorAll('.content')[1];
-
-      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
-    });
-
-    test('findLinesByRange', () => {
-      const lines = [];
-      const elems = [];
-      const start = 6;
-      const end = 10;
-      const count = end - start + 1;
-
-      builder.findLinesByRange(start, end, 'right', lines, elems);
-
-      assert.equal(lines.length, count);
-      assert.equal(elems.length, count);
-
-      for (let i = 0; i < 5; i++) {
-        assert.instanceOf(lines[i], GrDiffLine);
-        assert.equal(lines[i].afterNumber, start + i);
-        assert.instanceOf(elems[i], HTMLElement);
-        assert.equal(lines[i].text, elems[i].textContent);
-      }
-    });
-
-    test('renderContentByRange', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-      const start = 9;
-      const end = 14;
-      const count = end - start + 1;
-
-      builder.renderContentByRange(start, end, 'left');
-
-      assert.equal(spy.callCount, count);
-      spy.getCalls().forEach((call, i) => {
-        assert.equal(call.args[1].beforeNumber, start + i);
-      });
-    });
-
-    test('renderContentByRange non-existent elements', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-
-      sinon.stub(builder, 'getLineNumberEl').returns(
-          document.createElement('div')
-      );
-      sinon.stub(builder, 'findLinesByRange').callsFake(
-          (s, e, d, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-          });
-
-      builder.renderContentByRange(1, 10, 'left');
-      // Should be called only once because only one line had a corresponding
-      // element.
-      assert.equal(spy.callCount, 1);
-    });
-
-    test('getLineNumberEl side-by-side left', () => {
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl side-by-side right', () => {
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getLineNumberEl unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getNextContentOnSide side-by-side left', () => {
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide side-by-side right', () => {
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame;
-
-    setup(() => {
-      mockBlame = [
-        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, 'getBlameTdByLine')
-          .returns(null);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder.getBlameCommitForBaseLine(1));
-      assert.equal(builder.getBlameCommitForBaseLine(1).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(11));
-      assert.equal(builder.getBlameCommitForBaseLine(11).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(32));
-      assert.equal(builder.getBlameCommitForBaseLine(32).id, 'commit 2');
-
-      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
-    });
-
-    test('getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
-    });
-
-    test('createBlameCell', () => {
-      const mockBlameInfo = {
-        time: 1576155200,
-        id: 1234567890,
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [1],
-      };
-      const getBlameStub = sinon.stub(builder, 'getBlameCommitForBaseLine')
-          .returns(mockBlameInfo);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder.createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      expect(result).dom.to.equal(/* HTML */`
-        <span class="gr-diff style-scope">
-          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
-            12/12/2019
-          </a>
-          <span class="blameAuthor gr-diff style-scope">Clark</span>
-          <gr-hovercard class="gr-diff style-scope">
-            <span class="blameHoverCard gr-diff style-scope">
-              Commit 1234567890<br>
-              Author: Clark Kent<br>
-              Date: 12/12/2019<br>
-              <br>
-              Testing Commit
-            </span>
-          </gr-hovercard>
-        </span>
-      `);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
new file mode 100644
index 0000000..8ae08f4
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -0,0 +1,1131 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
+import './gr-diff-builder-element';
+import {
+  nextRender,
+  queryAndAssert,
+  stubBaseUrl,
+} from '../../../test/test-utils';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  Side,
+} from '../../../api/diff';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffBuilderElement} from './gr-diff-builder-element';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {BlameInfo} from '../../../types/common';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-builder tests', () => {
+  let element: GrDiffBuilderElement;
+  let builder: GrDiffBuilderLegacy;
+  let diffTable: HTMLTableElement;
+
+  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
+  const WBR_HTML = '<wbr class="style-scope gr-diff">';
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilderSideBySide(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    diffTable = await fixture(html`<table id="diffTable"></table>`);
+    element = new GrDiffBuilderElement();
+    element.diffElement = diffTable;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  test('line_length applied with <wbr> if line_wrapping is true', () => {
+    setBuilderPrefs({line_wrapping: true, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('line_length applied with line break if line_wrapping is false', () => {
+    setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 72);
+    });
+  });
+
+  test('createTextEl linewrap with tabs', () => {
+    setBuilderPrefs({tab_size: 4, line_length: 10});
+    const text = '\t'.repeat(7) + '!';
+    const el = builder.createTextEl(null, line(text));
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 4, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+      el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
+      newlineEl
+    );
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    diffTable.addEventListener('show-alert', errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub: sinon.SinonStub;
+    let keyLocations: KeyLocations;
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon
+        .stub(element.processor, 'process')
+        .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isFalse(processStub.lastCall.args[1]);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+  });
+
+  suite('rendering', () => {
+    let content: DiffContent[];
+    let outputEl: HTMLTableElement;
+    let keyLocations: KeyLocations;
+    let addColumnsStub: sinon.SinonStub;
+    let dispatchStub: sinon.SinonStub;
+    let builder: GrDiffBuilderSideBySide;
+
+    setup(() => {
+      const prefs = {...DEFAULT_PREFS};
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      outputEl = element.diffElement!;
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, 'getDiffBuilder').callsFake(() => {
+        builder = new GrDiffBuilderSideBySide(
+          {...createEmptyDiff(), content},
+          prefs,
+          outputEl
+        );
+        addColumnsStub = sinon.stub(builder, 'addColumns');
+        builder.buildSectionElement = function (group) {
+          const section = document.createElement('stub');
+          section.textContent = group.lines.reduce(
+            (acc, line) => acc + line.text,
+            ''
+          );
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {...createEmptyDiff(), content};
+      element.prefs = prefs;
+      element.render(keyLocations);
+    });
+
+    test('addColumns is called', () => {
+      assert.isTrue(addColumnsStub.called);
+    });
+
+    test('getGroupsByLineRange one line', () => {
+      const section = outputEl.querySelector<HTMLElement>(
+        'stub:nth-of-type(3)'
+      );
+      const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
+    });
+
+    test('getGroupsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
+      ];
+      const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
+      assert.equal(groups.length, 2);
+      assert.strictEqual(groups[0].element, section[0]);
+      assert.strictEqual(groups[1].element, section[1]);
+    });
+
+    test('render-start and render-content are fired', async () => {
+      await nextRender();
+      let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-start');
+      assert.include(firedEventTypes, 'render-progress');
+
+      await nextRender();
+      firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+
+    test('cancel cancels the processor', () => {
+      const processorCancelStub = sinon.stub(element.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      const keyLocations: KeyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      element.render(keyLocations);
+      // Make sure all listeners are installed.
+      await nextRender();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      topExpandCommonButton!.click();
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await nextRender();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+
+  suite('mock-diff', () => {
+    let builder: GrDiffBuilderSideBySide;
+    let diff: DiffInfo;
+    let keyLocations: KeyLocations;
+
+    setup(() => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      diff = createDiff();
+      element.diff = diff;
+
+      keyLocations = {left: {}, right: {}};
+
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 80,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+    });
+
+    test('aria-labels on added line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.right'
+      )[5];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
+    });
+
+    test('aria-labels on removed line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.left'
+      )[10];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(
+        deltaLineNumberButton.getAttribute('aria-label'),
+        '10 removed'
+      );
+    });
+
+    test('getContentByLine', () => {
+      let actual: HTMLElement | null;
+
+      actual = builder.getContentByLine(2, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(2, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(5, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
+
+      actual = builder.getContentByLine(5, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[1].b?.[0]);
+    });
+
+    test('getContentTdByLineEl works both with button and td', () => {
+      const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
+
+      const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
+      const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
+      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+      const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
+      const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
+      const contentTdRight = diffRow.querySelectorAll('.content')[1];
+
+      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonLeft),
+        contentTdLeft
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumTdRight),
+        contentTdRight
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonRight),
+        contentTdRight
+      );
+    });
+
+    test('findLinesByRange', () => {
+      const lines: GrDiffLine[] = [];
+      const elems: HTMLElement[] = [];
+      const start = 6;
+      const end = 10;
+      const count = end - start + 1;
+
+      builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
+
+      assert.equal(lines.length, count);
+      assert.equal(elems.length, count);
+
+      for (let i = 0; i < 5; i++) {
+        assert.instanceOf(lines[i], GrDiffLine);
+        assert.equal(lines[i].afterNumber, start + i);
+        assert.instanceOf(elems[i], HTMLElement);
+        assert.equal(lines[i].text, elems[i].textContent);
+      }
+    });
+
+    test('renderContentByRange', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+      const start = 9;
+      const end = 14;
+      const count = end - start + 1;
+
+      builder.renderContentByRange(start, end, Side.LEFT);
+
+      assert.equal(spy.callCount, count);
+      spy.getCalls().forEach((call, i: number) => {
+        assert.equal(call.args[1].beforeNumber, start + i);
+      });
+    });
+
+    test('renderContentByRange non-existent elements', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+
+      sinon
+        .stub(builder, 'getLineNumberEl')
+        .returns(document.createElement('div'));
+      sinon
+        .stub(builder, 'findLinesByRange')
+        .callsFake((_1, _2, _3, lines, elements) => {
+          // Add a line and a corresponding element.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          const tr = document.createElement('tr');
+          const td = document.createElement('td');
+          const el = document.createElement('div');
+          tr.appendChild(td);
+          td.appendChild(el);
+          elements?.push(el);
+
+          // Add 2 lines without corresponding elements.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+        });
+
+      builder.renderContentByRange(1, 10, Side.LEFT);
+      // Should be called only once because only one line had a corresponding
+      // element.
+      assert.equal(spy.callCount, 1);
+    });
+
+    test('getLineNumberEl side-by-side left', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl side-by-side right', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getLineNumberEl unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getNextContentOnSide side-by-side left', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(startElem);
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide side-by-side right', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame: BlameInfo[];
+
+    setup(() => {
+      mockBlame = [
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 1',
+          ranges: [
+            {start: 1, end: 2},
+            {start: 10, end: 16},
+          ],
+        },
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 2',
+          ranges: [
+            {start: 4, end: 10},
+            {start: 17, end: 32},
+          ],
+        },
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameTdByLine')
+        .returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.isOk(builder.getBlameCommitForBaseLine(1));
+      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
+
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
+    });
+
+    test('getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
+    });
+
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
+        id: '1234567890',
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [{start: 4, end: 10}],
+      };
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameCommitForBaseLine')
+        .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder.createBlameCell(line.beforeNumber);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      expect(result).dom.to.equal(/* HTML */ `
+        <span class="gr-diff style-scope">
+          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
+            12/12/2019
+          </a>
+          <span class="blameAuthor gr-diff style-scope">Clark</span>
+          <gr-hovercard class="gr-diff style-scope">
+            <span class="blameHoverCard gr-diff style-scope">
+              Commit 1234567890<br />
+              Author: Clark Kent<br />
+              Date: 12/12/2019<br />
+              <br />
+              Testing Commit
+            </span>
+          </gr-hovercard>
+        </span>
+      `);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index 7758aa0..c04d156 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -162,10 +162,8 @@
    *
    * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
    */
-  private getLineNumberEl(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null {
+  // visible for testing
+  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
     let row: HTMLElement | null = content;
     while (row && !row.classList.contains('diff-row')) row = row.parentElement;
     return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
@@ -237,7 +235,8 @@
     }
 
     const cell = createElementDiff('td', 'dividerCell');
-    cell.setAttribute('colspan', '3');
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+    cell.setAttribute('colspan', colspan);
     row.appendChild(cell);
 
     const contextControls = createElementDiff(
@@ -271,9 +270,13 @@
     row.appendChild(this.createBlameCell(0));
     row.appendChild(createElementDiff('td', 'contextLineNum'));
     if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td', 'sign'));
       row.appendChild(createElementDiff('td'));
     }
     row.appendChild(createElementDiff('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td', 'sign'));
+    }
     row.appendChild(createElementDiff('td'));
 
     return row;
@@ -288,6 +291,7 @@
     const td = createElementDiff('td');
     td.classList.add(side);
     if (line.type === GrDiffLineType.BLANK) {
+      td.classList.add('blankLineNum');
       return td;
     }
     if (line.type === GrDiffLineType.BOTH || line.type === type) {
@@ -343,7 +347,8 @@
     });
   }
 
-  protected createTextEl(
+  // visible for testing
+  createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
     side?: Side
@@ -352,6 +357,9 @@
     if (line.type !== GrDiffLineType.BLANK) {
       td.classList.add('content');
     }
+    if (side) {
+      td.classList.add(side);
+    }
 
     // If intraline info is not available, the entire line will be
     // considered as changed and marked as dark red / green color
@@ -436,8 +444,13 @@
 
   protected buildMoveControls(group: GrDiffGroup) {
     const movedIn = group.adds.length > 0;
-    const {numberOfCells, movedOutIndex, movedInIndex, lineNumberCols} =
-      this.getMoveControlsConfig();
+    const {
+      numberOfCells,
+      movedOutIndex,
+      movedInIndex,
+      lineNumberCols,
+      signCols,
+    } = this.getMoveControlsConfig();
 
     let controlsClass;
     let descriptionIndex;
@@ -458,6 +471,10 @@
       cells[index].classList.add('moveControlsLineNumCol');
     });
 
+    if (signCols) {
+      cells[signCols.left].classList.add('sign', 'left');
+      cells[signCols.right].classList.add('sign', 'right');
+    }
     const moveRangeHeader = createElementDiff('gr-range-header');
     moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
     moveRangeHeader.appendChild(descriptionTextDiv);
@@ -473,7 +490,8 @@
    * Create a blame cell for the given base line. Blame information will be
    * included in the cell if available.
    */
-  protected createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+  // visible for testing
+  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
     const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
     blameTd.setAttribute('data-line-number', lineNumber.toString());
     if (!lineNumber) return blameTd;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index 676c389..f2690bc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -16,7 +16,7 @@
  */
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 import {RenderPreferences} from '../../../api/diff';
@@ -36,14 +36,16 @@
 
   protected override getMoveControlsConfig() {
     return {
-      numberOfCells: 4,
-      movedOutIndex: 1,
-      movedInIndex: 3,
-      lineNumberCols: [0, 2],
+      numberOfCells: 6,
+      movedOutIndex: 2,
+      movedInIndex: 5,
+      lineNumberCols: [0, 3],
+      signCols: {left: 1, right: 4},
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup) {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup) {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
@@ -83,6 +85,8 @@
     col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
+    colgroup.appendChild(createElementDiff('col', 'sign left'));
+
     // Add left-side content.
     colgroup.appendChild(createElementDiff('col', 'left'));
 
@@ -91,6 +95,8 @@
     col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
+    colgroup.appendChild(createElementDiff('col', 'sign right'));
+
     // Add right-side content.
     colgroup.appendChild(document.createElement('col'));
 
@@ -120,10 +126,30 @@
   ) {
     const lineNumberEl = this.createLineEl(line, lineNumber, line.type, side);
     row.appendChild(lineNumberEl);
+    row.appendChild(this.createSignEl(line, side));
     row.appendChild(this.createTextEl(lineNumberEl, line, side));
   }
 
-  protected override getNextContentOnSide(
+  private createSignEl(line: GrDiffLine, side: Side): HTMLElement {
+    const td = createElementDiff('td', 'sign');
+    td.classList.add(side);
+    if (line.type === GrDiffLineType.BLANK) {
+      td.classList.add('blank');
+    } else if (line.type === GrDiffLineType.ADD && side === Side.RIGHT) {
+      td.classList.add('add');
+      td.innerText = '+';
+    } else if (line.type === GrDiffLineType.REMOVE && side === Side.LEFT) {
+      td.classList.add('remove');
+      td.innerText = '-';
+    }
+    if (!line.hasIntralineInfo) {
+      td.classList.add('no-intraline-info');
+    }
+    return td;
+  }
+
+  // visible for testing
+  override getNextContentOnSide(
     content: HTMLElement,
     side: Side
   ): HTMLElement | null {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 4145485..0c9d1d9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -43,7 +43,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup): HTMLElement {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
deleted file mode 100644
index 5f3fb72..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import './gr-diff-builder-unified.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs;
-  let outputEl;
-  let diffBuilder;
-
-  setup(()=> {
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[0].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[1].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.left').textContent,
-          lines[2].beforeNumber);
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group.dueToRebase = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[3].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[3].querySelector('.content').textContent, lines[3].text);
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[3].text);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
new file mode 100644
index 0000000..7a9d06d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
@@ -0,0 +1,282 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-group';
+import './gr-diff-builder';
+import './gr-diff-builder-unified';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {createDiff} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../utils/common-util';
+
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs: DiffPreferencesInfo;
+  let outputEl: HTMLElement;
+  let diffBuilder: GrDiffBuilderUnified;
+
+  setup(() => {
+    prefs = {
+      ...createDefaultDiffPrefs(),
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
+    });
+
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 3);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[0].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[1].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
+        lines[2].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+    });
+  });
+
+  suite('buildSectionElement for moved chunks', () => {
+    test('creates a moved out group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 15),
+        new GrDiffLine(GrDiffLineType.REMOVE, 16),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved out');
+    });
+
+    test('creates a moved in group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.ADD, 37),
+        new GrDiffLine(GrDiffLineType.ADD, 38),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved in');
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 1),
+        new GrDiffLine(GrDiffLineType.REMOVE, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+    });
+
+    test('creates the section', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        dueToRebase: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[3], '.content').textContent,
+        lines[3].text
+      );
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[3].text
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index 8b7a308..4b664e2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -89,7 +89,8 @@
 
   protected readonly numLinesLeft: number;
 
-  protected readonly _prefs: DiffPreferencesInfo;
+  // visible for testing
+  readonly _prefs: DiffPreferencesInfo;
 
   protected readonly renderPrefs?: RenderPreferences;
 
@@ -181,7 +182,7 @@
     for (const group of groups) {
       this.emitGroup(group, contextControlSection);
     }
-    contextControlSection?.remove();
+    if (contextControlSection) contextControlSection.remove();
   }
 
   findGroup(side: Side, line: LineNumber) {
@@ -194,17 +195,23 @@
     group.element = element;
   }
 
-  private getGroupsByLineRange(
+  // visible for testing
+  getGroupsByLineRange(
     startLine: LineNumber,
     endLine: LineNumber,
     side: Side
-  ) {
+  ): GrDiffGroup[] {
     const startIndex = this.groups.findIndex(group =>
       group.containsLine(side, startLine)
     );
-    const endIndex = this.groups.findIndex(group =>
+    if (startIndex === -1) return [];
+    let endIndex = this.groups.findIndex(group =>
       group.containsLine(side, endLine)
     );
+    // Not all groups may have been processed yet (i.e. this.groups is still
+    // incomplete). In that case let's just return *all* groups until the end
+    // of the array.
+    if (endIndex === -1) endIndex = this.groups.length - 1;
     // The filter preserves the legacy behavior to only return non-context
     // groups
     return this.groups
@@ -252,7 +259,8 @@
    *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
    *        really need to support null/undefined? Also change to camelCase.
    */
-  protected findLinesByRange(
+  // visible for testing
+  findLinesByRange(
     start: LineNumber,
     end: LineNumber,
     side: Side,
@@ -324,6 +332,7 @@
     movedOutIndex: number;
     movedInIndex: number;
     lineNumberCols: number[];
+    signCols?: {left: number; right: number};
   };
 
   /**
@@ -346,9 +355,8 @@
    *
    * @return The commit information.
    */
-  protected getBlameCommitForBaseLine(
-    lineNum: LineNumber
-  ): BlameInfo | undefined {
+  // visible for testing
+  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
     for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index 3ab02e1..0a60bdb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -200,7 +200,7 @@
       // If we are moving out of the currently hovered element, cancel the
       // update task.
       this.hoveredElement = undefined;
-      this.updateTokenTask?.cancel();
+      if (this.updateTokenTask) this.updateTokenTask.cancel();
     }
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 35b89ec..e80d86b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -109,7 +109,8 @@
    */
   initialLineNumber: number | null = null;
 
-  private cursorManager = new GrCursorManager();
+  // visible for testing
+  cursorManager = new GrCursorManager();
 
   private targetSubscription?: Subscription;
 
@@ -335,6 +336,10 @@
     this.preventAutoScrollOnManualScroll = true;
   };
 
+  private _boundHandleDiffRenderProgress = () => {
+    this._updateStops();
+  };
+
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
@@ -546,6 +551,10 @@
     );
     diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
     diff.removeEventListener(
+      'render-progress',
+      this._boundHandleDiffRenderProgress
+    );
+    diff.removeEventListener(
       'render-content',
       this._boundHandleDiffRenderContent
     );
@@ -561,6 +570,10 @@
       this.boundHandleDiffLoadingChanged
     );
     diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.addEventListener(
+      'render-progress',
+      this._boundHandleDiffRenderProgress
+    );
     diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
     diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
deleted file mode 100644
index 609b33e..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ /dev/null
@@ -1,696 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff.js';
-import './gr-diff-cursor.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {listenOnce, mockPromise} from '../../../test/test-utils.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {GrDiffCursor} from './gr-diff-cursor.js';
-
-suite('gr-diff-cursor tests', () => {
-  let cursor;
-  let diffElement;
-  let diff;
-
-  setup(async () => {
-    diffElement = await fixture(html`<gr-diff></gr-diff>`);
-    cursor = new GrDiffCursor();
-
-    // Register the diff with the cursor.
-    cursor.replaceDiffs([diffElement]);
-
-    diffElement.loggedIn = false;
-    diffElement.comments = {
-      left: [],
-      right: [],
-      meta: {},
-    };
-    diffElement.path = 'some/path.ts';
-    const promise = mockPromise();
-    const setupDone = () => {
-      cursor._updateStops();
-      cursor.moveToFirstChunk();
-      diffElement.removeEventListener('render', setupDone);
-      promise.resolve();
-    };
-    diffElement.addEventListener('render', setupDone);
-
-    diff = getMockDiffResponse();
-    diffElement.prefs = createDefaultDiffPrefs();
-    diffElement.diff = diff;
-    await promise;
-  });
-
-  test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
-    assert.isOk(cursor.diffRow);
-
-    const firstDeltaRow = diffElement.shadowRoot
-        .querySelector('.section.delta .diff-row');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-
-    cursor.moveDown();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-    cursor.moveUp();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursor.diffRow, firstDeltaRow);
-  });
-
-  test('moveToFirstChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {b: ['new line 1']},
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    // The file comment button, if present, is a cursor stop. Ensure
-    // moveToFirstChunk() works correctly even if the button is not shown.
-    diffElement.prefs.show_file_comment_button = false;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('moveToLastChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-        {b: ['new line 3']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('cursor scroll behavior', () => {
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-
-    diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursor.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursor.cursorManager.focusOnMove);
-
-    diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    cursor.reInitCursor();
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-
-    diffElement.dispatchEvent(
-        new CustomEvent('line-selected', {
-          detail: {number: '123', side: 'right', path: 'some/file'},
-        }));
-
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], '123');
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
-  });
-
-  suite('unified diff', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      // We must allow the diff to re-render after setting the viewMode.
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.viewMode = 'UNIFIED_DIFF';
-      await promise;
-    });
-
-    test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursor.diffRow);
-
-      let firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      cursor.moveDown();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-      cursor.moveUp();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursor.diffRow, firstDeltaRow);
-    });
-  });
-
-  test('cursor side functionality', () => {
-    // The side only applies to side-by-side mode, which should be the default
-    // mode.
-    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-    const firstDeltaSection = diffElement.shadowRoot
-        .querySelector('.section.delta');
-    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-    // Because the first delta in this diff is on the right, it should be set
-    // to the right side.
-    assert.equal(cursor.side, 'right');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-    const firstIndex = cursor.cursorManager.index;
-
-    // Move the side to the left. Because this delta only has a right side, we
-    // should be moved up to the previous line where there is content on the
-    // right. The previous row is part of the previous section.
-    cursor.moveLeft();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.cursorManager.index, firstIndex - 1);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.previousSibling);
-
-    // If we move down, we should skip everything in the first delta because
-    // we are on the left side and the first delta has no content on the left.
-    cursor.moveDown();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.isTrue(cursor.cursorManager.index > firstIndex);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.nextSibling);
-  });
-
-  test('chunk skip functionality', () => {
-    const chunks = diffElement.root.querySelectorAll(
-        '.section.delta');
-    const indexOfChunk = function(chunk) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
-
-    // We should be initialized to the first chunk. Since this chunk only has
-    // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, 0);
-    assert.equal(cursor.side, 'right');
-
-    // Move to the next chunk.
-    cursor.moveToNextChunk();
-
-    // Since this chunk only has content on the left side. we should have been
-    // automatically moved over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursor.side, 'left');
-  });
-
-  suite('moved chunks without line range)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved in');
-      assert.equal(movedOut.textContent, 'Moved out');
-    });
-  });
-
-  suite('moved chunks (moveDetails)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 4, end: 6}},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 2, end: 4}},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
-    });
-
-    test('startLineAnchor of movedIn chunk fires events', async () => {
-      const [movedIn] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [startLineAnchor] = movedIn.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
-        promise.resolve();
-      };
-      assert.equal(startLineAnchor.textContent, '4');
-      startLineAnchor
-          .addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(startLineAnchor);
-      await promise;
-    });
-
-    test('endLineAnchor of movedOut fires events', async () => {
-      const [, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [, endLineAnchor] = movedOut.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
-        promise.resolve();
-      };
-      assert.equal(endLineAnchor.textContent, '4');
-      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(endLineAnchor);
-      await promise;
-    });
-  });
-
-  test('initialLineNumber not provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToNumStub.called);
-      assert.isTrue(moveToChunkStub.called);
-      assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(getMockDiffResponse());
-    await promise;
-  });
-
-  test('initialLineNumber provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToChunkStub.called);
-      assert.isTrue(moveToNumStub.called);
-      assert.equal(moveToNumStub.lastCall.args[0], 10);
-      assert.equal(moveToNumStub.lastCall.args[1], 'right');
-      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    cursor.initialLineNumber = 10;
-    cursor.side = 'right';
-
-    diffElement._diffChanged(getMockDiffResponse());
-    await promise;
-  });
-
-  test('getTargetDiffElement', () => {
-    cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
-    assert.equal(
-        cursor.getTargetDiffElement(),
-        diffElement
-    );
-  });
-
-  suite('createCommentInPlace', () => {
-    setup(() => {
-      diffElement.loggedIn = true;
-    });
-
-    test('adds new draft for selected line on the left', async () => {
-      cursor.moveToLineNumber(2, 'left');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(side, 'left');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('adds draft for selected line on the right', async () => {
-      cursor.moveToLineNumber(4, 'right');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('creates comment for range if selected', async () => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.$.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sinon.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
-      cursor.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // Revision line 4 is up.
-    cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 4});
-
-    // Base line 4 is left.
-    cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
-
-    // Moving to the next chunk takes it back to the start.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // The following chunk is a removal starting on line 10 of the base.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: true, number: 10});
-
-    // Should be null if there is no selection.
-    cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
-  });
-
-  test('_findRowByNumberAndFile', () => {
-    // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[9];
-
-    // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
-  });
-
-  test('expand context updates stops', async () => {
-    sinon.spy(cursor, '_updateStops');
-    MockInteractions.tap(diffElement.shadowRoot
-        .querySelector('gr-context-controls').shadowRoot
-        .querySelector('.showContext'));
-    await flush();
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  test('updates stops when loading changes', () => {
-    sinon.spy(cursor, '_updateStops');
-    diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  suite('multi diff', () => {
-    let diffElements;
-
-    setup(async () => {
-      diffElements = [
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-      ];
-      cursor = new GrDiffCursor();
-
-      // Register the diff with the cursor.
-      cursor.replaceDiffs(diffElements);
-
-      for (const el of diffElements) {
-        el.prefs = createDefaultDiffPrefs();
-      }
-    });
-
-    function getTargetDiffIndex() {
-      // Mocha has a bug where when `assert.equals` fails, it will try to
-      // JSON.stringify the operands, which fails when they are cyclic structures
-      // like GrDiffElement. The failure is difficult to attribute to a specific
-      // assertion because of the async nature assertion errors are handled and
-      // can cause the test simply timing out, causing a lot of debugging headache.
-      // Working with indices circumvents the problem.
-      return diffElements.indexOf(cursor.getTargetDiffElement());
-    }
-
-    test('do not skip loading diffs', async () => {
-      const diffRenderedPromises =
-          diffElements.map(diffEl => listenOnce(diffEl, 'render'));
-
-      diffElements[0].diff = getMockDiffResponse();
-      diffElements[2].diff = getMockDiffResponse();
-      await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
-
-      const lastLine = diffElements[0].diff.meta_b.lines;
-
-      // Goto second last line of the first diff
-      cursor.moveToLineNumber(lastLine - 1, 'right');
-      assert.equal(
-          cursor.getTargetLineElement().textContent, lastLine - 1);
-
-      // Can move down until we reach the loading file
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Cannot move down while still loading the diff we would switch to
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Diff 1 finishing to load
-      diffElements[1].diff = getMockDiffResponse();
-      await diffRenderedPromises[1];
-
-      // Now we can go down
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement().textContent, 'File');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
new file mode 100644
index 0000000..ac9b407
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,693 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffCursor} from './gr-diff-cursor';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+  let cursor: GrDiffCursor;
+  let diffElement: GrDiff;
+  let diff: DiffInfo;
+
+  setup(async () => {
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    cursor = new GrDiffCursor();
+
+    // Register the diff with the cursor.
+    cursor.replaceDiffs([diffElement]);
+
+    diffElement.loggedIn = false;
+    diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
+    const setupDone = () => {
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      promise.resolve();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    diff = createDiff();
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
+    await promise;
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursor.diffRow);
+
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta .diff-row'
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+
+    cursor.moveDown();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+
+    cursor.moveUp();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+  });
+
+  test('moveToFirstChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs!.show_file_comment_button = false;
+    await waitForEventOnce(diffElement, 'render');
+
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToNextChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await waitForEventOnce(diffElement, 'render');
+    cursor._updateStops();
+
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
+
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+    diffElement.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+      })
+    );
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 123);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(async () => {
+      diffElement.viewMode = DiffViewMode.UNIFIED;
+      await waitForEventOnce(diffElement, 'render');
+      cursor.reInitCursor();
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursor.diffRow);
+
+      const firstDeltaRow = queryAndAssert<HTMLElement>(
+        diffElement,
+        '.section.delta .diff-row'
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+
+      cursor.moveDown();
+
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+
+      cursor.moveUp();
+
+      assert.notEqual(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const firstDeltaSection = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta'
+    );
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      firstDeltaSection,
+      '.diff-row'
+    );
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursor.side, Side.RIGHT);
+    assert.equal(cursor.diffRow, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursor.moveLeft();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(
+      cursor.diffRow!.parentElement,
+      firstDeltaSection.previousSibling
+    );
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursor.moveDown();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow!.parentElement, firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    const indexOfChunk = function (chunk: HTMLElement) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Move to the next chunk.
+    cursor.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursor.side, Side.LEFT);
+  });
+
+  suite('moved chunks without line range)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved in');
+      assert.equal(movedOut.textContent, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 4, end: 6}},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 2, end: 4}},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
+      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+    });
+
+    test('startLineAnchor of movedIn chunk fires events', async () => {
+      const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+        promise.resolve();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor.addEventListener(
+        'moved-link-clicked',
+        onMovedLinkClicked
+      );
+      startLineAnchor.click();
+      await promise;
+    });
+
+    test('endLineAnchor of movedOut fires events', async () => {
+      const [, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+        promise.resolve();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      endLineAnchor.click();
+      await promise;
+    });
+  });
+
+  test('initialLineNumber not provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon
+      .stub(cursor, 'moveToFirstChunk')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('initialLineNumber provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon
+      .stub(cursor, 'moveToLineNumber')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    cursor.initialLineNumber = 10;
+    cursor.side = Side.RIGHT;
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('getTargetDiffElement', () => {
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
+    assert.equal(cursor.getTargetDiffElement(), diffElement);
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, Side.LEFT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.LEFT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, Side.RIGHT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('creates comment for range if selected', async () => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.highlights.selectedRange = {
+        side: Side.RIGHT,
+        range: someRange,
+      };
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(
+        diffElement,
+        'createRangeComment'
+      );
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
+    const row = rows[9];
+    assert.ok(row);
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+  });
+
+  test('expand context updates stops', async () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    const controls = queryAndAssert(diffElement, 'gr-context-controls');
+    const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+    showContext.click();
+    await waitForEventOnce(diffElement, 'render');
+    assert.isTrue(spy.called);
+  });
+
+  test('updates stops when loading changes', () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(spy.called);
+  });
+
+  suite('multi diff', () => {
+    let diffElements: GrDiff[];
+
+    setup(async () => {
+      diffElements = [
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+      ];
+      cursor = new GrDiffCursor();
+
+      // Register the diff with the cursor.
+      cursor.replaceDiffs(diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      const target = cursor.getTargetDiffElement();
+      assertIsDefined(target);
+      return diffElements.indexOf(target);
+    }
+
+    test('do not skip loading diffs', async () => {
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
+      await waitForEventOnce(diffElements[0], 'render');
+      await waitForEventOnce(diffElements[2], 'render');
+
+      const lastLine = diffElements[0].diff.meta_b?.lines;
+      assertIsDefined(lastLine);
+
+      // Goto second last line of the first diff
+      cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        `${lastLine - 1}`
+      );
+
+      // Can move down until we reach the loading file
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Cannot move down while still loading the diff we would switch to
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = createDiff();
+      await waitForEventOnce(diffElements[1], 'render');
+
+      // Now we can go down
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 4c51201..0714645 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -1,43 +1,25 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-highlight_html';
 import {GrAnnotation} from './gr-annotation';
 import {normalize} from './gr-range-normalizer';
 import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property} from '@polymer/decorators';
 import {Side} from '../../../constants/constants';
 import {CommentRange} from '../../../types/common';
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {FILE} from '../gr-diff/gr-diff-line';
 import {
   getLineElByChild,
   getLineNumberByChild,
-  getRange,
-  getSide,
   getSideByLineEl,
   GrDiffThreadElement,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -56,51 +38,65 @@
   end: NormalizedPosition | null;
 }
 
-@customElement('gr-diff-highlight')
-export class GrDiffHighlight extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+  getContentTdByLineEl(lineEl?: Element): Element | null;
+}
 
-  @property({type: Array, notify: true})
-  commentRanges: SidedRange[] = [];
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
-
-  @property({type: Object, notify: true})
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
   selectedRange?: SidedRange;
 
+  private diffBuilder?: DiffBuilderInterface;
+
+  private diffTable?: HTMLElement;
+
   private selectionChangeTask?: DelayedTask;
 
-  constructor() {
-    super();
-    this.addEventListener('comment-thread-mouseleave', e =>
-      this._handleCommentThreadMouseleave(e)
+  init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+    this.cleanup();
+
+    this.diffTable = diffTable;
+    this.diffBuilder = diffBuilder;
+
+    diffTable.addEventListener(
+      'comment-thread-mouseleave',
+      this.handleCommentThreadMouseleave
     );
-    this.addEventListener('comment-thread-mouseenter', e =>
-      this._handleCommentThreadMouseenter(e)
+    diffTable.addEventListener(
+      'comment-thread-mouseenter',
+      this.handleCommentThreadMouseenter
     );
-    this.addEventListener('create-comment-requested', e =>
-      this._handleRangeCommentRequest(e)
+    diffTable.addEventListener(
+      'create-comment-requested',
+      this.handleRangeCommentRequest
     );
   }
 
-  override disconnectedCallback() {
+  cleanup() {
     this.selectionChangeTask?.cancel();
-    super.disconnectedCallback();
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
+    if (this.diffTable) {
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseleave',
+        this.handleCommentThreadMouseleave
+      );
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseenter',
+        this.handleCommentThreadMouseenter
+      );
+      this.diffTable.removeEventListener(
+        'create-comment-requested',
+        this.handleRangeCommentRequest
+      );
     }
-    return this._cachedDiffBuilder;
   }
 
   /**
@@ -129,18 +125,17 @@
     // removed.
     // If you wait longer than 50 ms, then you don't properly catch a very
     // quick 'c' press after the selection change. If you wait less than 10
-    // ms, then you will have about 50 _handleSelection calls when doing a
+    // ms, then you will have about 50 handleSelection() calls when doing a
     // simple drag for select.
     this.selectionChangeTask = debounce(
       this.selectionChangeTask,
-      () => this._handleSelection(selection, isMouseUp),
+      () => this.handleSelection(selection, isMouseUp),
       10
     );
   }
 
-  _getThreadEl(e: Event): GrDiffThreadElement | null {
-    const path = (dom(e) as EventApi).path || [];
-    for (const pathEl of path) {
+  private getThreadEl(e: Event): GrDiffThreadElement | null {
+    for (const pathEl of e.composedPath()) {
       if (
         pathEl instanceof HTMLElement &&
         pathEl.classList.contains('comment-thread')
@@ -151,130 +146,74 @@
     return null;
   }
 
-  _toggleRangeElHighlight(
-    threadEl: GrDiffThreadElement,
+  private toggleRangeElHighlight(
+    threadEl: GrDiffThreadElement | null,
     highlightRange = false
   ) {
-    // We don't want to re-create the line just for highlighting the range which
-    // is creating annoying bugs: @see Issue 12934
-    // As gr-ranged-comment-layer now does not notify the layer re-render and
-    // lack of access to the thread or the lineEl from the ranged-comment-layer,
-    // need to update range class for styles here.
-    let curNode: HTMLElement | null = threadEl.assignedSlot;
-    while (curNode) {
-      if (curNode.nodeName === 'TABLE') break;
-      curNode = curNode.parentElement;
-    }
-    if (curNode?.querySelectorAll) {
-      if (highlightRange) {
-        const rangeNodes = curNode.querySelectorAll(
-          `.range.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.add('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.add('rangeHoverHighlight')
-            );
-        }
-      } else {
-        const rangeNodes = curNode.querySelectorAll(
-          `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.remove('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHoverHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.remove('rangeHoverHighlight')
-            );
-        }
-      }
-    }
-  }
-
-  _handleCommentThreadMouseenter(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], true);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
-  }
-
-  _handleCommentThreadMouseleave(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], false);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
-  }
-
-  _indexForThreadEl(threadEl: HTMLElement) {
-    const side = getSide(threadEl);
-    const range = getRange(threadEl);
-    if (!side || !range) return undefined;
-    return this._indexOfCommentRange(side, range);
-  }
-
-  _indexOfCommentRange(side: Side, range: CommentRange) {
-    function rangesEqual(a: CommentRange, b: CommentRange) {
-      if (!a && !b) {
-        return true;
-      }
-      if (!a || !b) {
-        return false;
-      }
-      return (
-        a.start_line === b.start_line &&
-        a.start_character === b.start_character &&
-        a.end_line === b.end_line &&
-        a.end_character === b.end_character
+    const rootId = threadEl?.rootId;
+    if (!rootId) return;
+    if (!this.diffTable) return;
+    if (highlightRange) {
+      const selector = `.range.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.add('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
       );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.add('rangeHoverHighlight')
+        );
+    } else {
+      const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.remove('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHoverHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.remove('rangeHoverHighlight')
+        );
     }
-
-    return this.commentRanges.findIndex(
-      commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range)
-    );
   }
 
+  private handleCommentThreadMouseenter = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  };
+
+  private handleCommentThreadMouseleave = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  };
+
   /**
    * Get current normalized selection.
    * Merges multiple ranges, accounts for triple click, accounts for
    * syntax highligh, convert native DOM Range objects to Gerrit concepts
    * (line, side, etc).
    */
-  _getNormalizedRange(selection: Selection | Range) {
+  private getNormalizedRange(selection: Selection | Range) {
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
        we can get is a single Range */
     if (selection instanceof Range) {
-      return this._normalizeRange(selection);
+      return this.normalizeRange(selection);
     }
     const rangeCount = selection.rangeCount;
     if (rangeCount === 0) {
       return null;
     } else if (rangeCount === 1) {
-      return this._normalizeRange(selection.getRangeAt(0));
+      return this.normalizeRange(selection.getRangeAt(0));
     } else {
-      const startRange = this._normalizeRange(selection.getRangeAt(0));
-      const endRange = this._normalizeRange(
+      const startRange = this.normalizeRange(selection.getRangeAt(0));
+      const endRange = this.normalizeRange(
         selection.getRangeAt(rangeCount - 1)
       );
       return {
@@ -289,15 +228,15 @@
    *
    * @return fixed normalized range
    */
-  _normalizeRange(domRange: Range): NormalizedRange {
+  private normalizeRange(domRange: Range): NormalizedRange {
     const range = normalize(domRange);
-    return this._fixTripleClickSelection(
+    return this.fixTripleClickSelection(
       {
-        start: this._normalizeSelectionSide(
+        start: this.normalizeSelectionSide(
           range.startContainer,
           range.startOffset
         ),
-        end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
+        end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
       },
       domRange
     );
@@ -313,7 +252,7 @@
    * @param domRange DOM Range object
    * @return fixed normalized range
    */
-  _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+  private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
     if (!range.start) {
       // Selection outside of current diff.
       return range;
@@ -334,7 +273,7 @@
       end.column === 0 &&
       end.line === start.line + 1;
     const content = domRange.cloneContents().querySelector('.contentText');
-    const lineLength = (content && this._getLength(content)) || 0;
+    const lineLength = (content && this.getLength(content)) || 0;
     if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
       // Move the selection to the end of the previous line.
       range.end = {
@@ -355,12 +294,14 @@
    * @param node td.content child
    * @param offset offset within node
    */
-  _normalizeSelectionSide(
+  private normalizeSelectionSide(
     node: Node | null,
     offset: number
   ): NormalizedPosition | null {
     let column;
-    if (!node || !this.contains(node)) return null;
+    if (!this.diffTable) return null;
+    if (!this.diffBuilder) return null;
+    if (!node || !this.diffTable.contains(node)) return null;
     const lineEl = getLineElByChild(node);
     if (!lineEl) return null;
     const side = getSideByLineEl(lineEl);
@@ -376,10 +317,10 @@
     } else {
       const thread = contentTd.querySelector('.comment-thread');
       if (thread?.contains(node)) {
-        column = this._getLength(contentText);
+        column = this.getLength(contentText);
         node = contentText;
       } else {
-        column = this._convertOffsetToColumn(node, offset);
+        column = this.convertOffsetToColumn(node, offset);
       }
     }
 
@@ -398,7 +339,8 @@
    * collapsed section, so don't need to worry about this case for
    * positioning the tooltip.
    */
-  _positionActionBox(
+  // visible for testing
+  positionActionBox(
     actionBox: GrSelectionActionBox,
     startLine: number,
     range: Text | Element | Range
@@ -412,7 +354,7 @@
     actionBox.placeBelow(range);
   }
 
-  _isRangeValid(range: NormalizedRange | null) {
+  private isRangeValid(range: NormalizedRange | null) {
     if (!range || !range.start || !range.start.node || !range.end) {
       return false;
     }
@@ -425,15 +367,16 @@
     );
   }
 
-  _handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+  // visible for testing
+  handleSelection(selection: Selection | Range, isMouseUp: boolean) {
     /* On Safari, the selection events may return a null range that should
        be ignored */
-    if (!selection) {
-      return;
-    }
-    const normalizedRange = this._getNormalizedRange(selection);
-    if (!this._isRangeValid(normalizedRange)) {
-      this._removeActionBox();
+    if (!selection) return;
+    if (!this.diffTable) return;
+
+    const normalizedRange = this.getNormalizedRange(selection);
+    if (!this.isRangeValid(normalizedRange)) {
+      this.removeActionBox();
       return;
     }
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
@@ -463,8 +406,8 @@
       // start.column with the content length), we just check if the selection
       // is empty to see that it's at the end of a line.
       const content = domRange.cloneContents().querySelector('.contentText');
-      if (isMouseUp && this._getLength(content) === 0) {
-        this._fireCreateRangeComment(start.side, {
+      if (isMouseUp && this.getLength(content) === 0) {
+        this.fireCreateRangeComment(start.side, {
           start_line: start.line,
           start_character: 0,
           end_line: start.line,
@@ -474,12 +417,10 @@
       return;
     }
 
-    let actionBox = this.shadowRoot!.querySelector(
-      'gr-selection-action-box'
-    ) as GrSelectionActionBox | null;
+    let actionBox = this.diffTable.querySelector('gr-selection-action-box');
     if (!actionBox) {
       actionBox = document.createElement('gr-selection-action-box');
-      this.root!.insertBefore(actionBox, this.root!.firstElementChild);
+      this.diffTable.appendChild(actionBox);
     }
     this.selectedRange = {
       range: {
@@ -491,10 +432,10 @@
       side: start.side,
     };
     if (start.line === end.line) {
-      this._positionActionBox(actionBox, start.line, domRange);
+      this.positionActionBox(actionBox, start.line, domRange);
     } else if (start.node instanceof Text) {
       if (start.column) {
-        this._positionActionBox(
+        this.positionActionBox(
           actionBox,
           start.line,
           start.node.splitText(start.column)
@@ -507,44 +448,41 @@
       (start.node.firstChild instanceof Element ||
         start.node.firstChild instanceof Text)
     ) {
-      this._positionActionBox(actionBox, start.line, start.node.firstChild);
+      this.positionActionBox(actionBox, start.line, start.node.firstChild);
     } else if (start.node instanceof Element || start.node instanceof Text) {
-      this._positionActionBox(actionBox, start.line, start.node);
+      this.positionActionBox(actionBox, start.line, start.node);
     } else {
       console.warn('Failed to position comment action box.');
-      this._removeActionBox();
+      this.removeActionBox();
     }
   }
 
-  _fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.dispatchEvent(
+  private fireCreateRangeComment(side: Side, range: CommentRange) {
+    this.diffTable?.dispatchEvent(
       new CustomEvent('create-range-comment', {
         detail: {side, range},
         composed: true,
         bubbles: true,
       })
     );
-    this._removeActionBox();
+    this.removeActionBox();
   }
 
-  _handleRangeCommentRequest(e: Event) {
+  private handleRangeCommentRequest = (e: Event) => {
     e.stopPropagation();
-    if (!this.selectedRange) {
-      throw Error('Selected Range is needed for new range comment!');
-    }
+    assertIsDefined(this.selectedRange, 'selectedRange');
     const {side, range} = this.selectedRange;
-    this._fireCreateRangeComment(side, range);
-  }
+    this.fireCreateRangeComment(side, range);
+  };
 
-  _removeActionBox() {
+  // visible for testing
+  removeActionBox() {
     this.selectedRange = undefined;
-    const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
-    if (actionBox) {
-      this.root!.removeChild(actionBox);
-    }
+    const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+    if (actionBox) actionBox.remove();
   }
 
-  _convertOffsetToColumn(el: Node, offset: number) {
+  private convertOffsetToColumn(el: Node, offset: number) {
     if (el instanceof Element && el.classList.contains('content')) {
       return offset;
     }
@@ -554,7 +492,7 @@
     ) {
       if (el.previousSibling) {
         el = el.previousSibling;
-        offset += this._getLength(el);
+        offset += this.getLength(el);
       } else {
         el = el.parentElement!;
       }
@@ -568,18 +506,24 @@
    *
    * @param node this is sometimes passed as null.
    */
-  _getLength(node: Node | null): number {
+  // visible for testing
+  getLength(node: Node | null): number {
     if (node === null) return 0;
     if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(queryAndAssert(node, '.contentText'));
+      return this.getLength(queryAndAssert(node, '.contentText'));
     } else {
       return GrAnnotation.getLength(node);
     }
   }
 }
 
+export interface CreateRangeCommentEventDetail {
+  side: Side;
+  range: CommentRange;
+}
+
 declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-highlight': GrDiffHighlight;
+  interface HTMLElementEventMap {
+    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
deleted file mode 100644
index 5a6cb1c..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      position: relative;
-    }
-    gr-selection-action-box {
-      /**
-         * Needs z-index to appear above wrapped content, since it's inserted
-         * into DOM before it.
-         */
-      z-index: 10;
-    }
-  </style>
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
deleted file mode 100644
index 4c1295f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ /dev/null
@@ -1,589 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-highlight.js';
-import {_getTextOffset} from './gr-range-normalizer.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<style>
-      .tab-indicator:before {
-        color: #C62828;
-        /* >> character */
-        content: '\\00BB';
-      }
-    </style>
-    <gr-diff-highlight>
-      <table id="diffTable">
-
-        <tbody class="section both">
-           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
-            <td class="right lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-<tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="119"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
-                [Yet another random diff thread content here]
-            </div></td>
-            <td class="right lineNum" data-value="120"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="141"></td>
-            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-            <td class="right lineNum" data-value="130"></td>
-            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section contextControl">
-          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-            <td class="right contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta total">
-          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
-            <td class="left"></td>
-            <td class="blank"></td>
-            <td class="right lineNum" data-value="146"></td>
-            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText"></div></td>
-            <td class="right lineNum" data-value="147"></td>
-            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
-          </tr>
-        </tbody>
-
-      </table>
-    </gr-diff-highlight>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-highlight', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate()[1];
-  });
-
-  suite('comment events', () => {
-    let builder;
-
-    setup(() => {
-      builder = {
-        getContentsByLineRange: sinon.stub().returns([]),
-        getLineElByChild: sinon.stub().returns({}),
-        getSideByLineEl: sinon.stub().returns('other-side'),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    test('comment-thread-mouseenter from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test('comment-thread-mouseenter from ranged comment causes set', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      threadEl.setAttribute('range', JSON.stringify({
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }));
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right', range: {
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isTrue(element.set.called);
-      const args = element.set.lastCall.args;
-      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-      assert.deepEqual(args[1], true);
-    });
-
-    test('comment-thread-mouseleave from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseleave', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test(`create-range-comment for range when create-comment-requested
-          is fired`, () => {
-      sinon.stub(element, '_removeActionBox');
-      element.selectedRange = {
-        side: 'left',
-        range: {
-          start_line: 7,
-          start_character: 11,
-          end_line: 24,
-          end_character: 42,
-        },
-      };
-      const requestEvent = new CustomEvent('create-comment-requested');
-      let createRangeEvent;
-      element.addEventListener('create-range-comment', e => {
-        createRangeEvent = e;
-      });
-      element.dispatchEvent(requestEvent);
-      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
-      assert.isTrue(element._removeActionBox.called);
-    });
-  });
-
-  suite('selection', () => {
-    let diff;
-    let builder;
-    let contentStubs;
-
-    const stubContent = (line, side, opt_child) => {
-      const contentTd = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"] ~ .content`);
-      const contentText = contentTd.querySelector('.contentText');
-      const lineEl = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"]`);
-      contentStubs.push({
-        lineEl,
-        contentTd,
-        contentText,
-      });
-      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
-      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
-      builder.getSideByLineEl.withArgs(lineEl).returns(side);
-      return contentText;
-    };
-
-    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-      const selection = document.getSelection();
-      const range = document.createRange();
-      range.setStart(startNode, startOffset);
-      range.setEnd(endNode, endOffset);
-      selection.addRange(range);
-      element._handleSelection(selection);
-    };
-
-    const getLineElByChild = node => {
-      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
-      return stubs && stubs.lineEl;
-    };
-
-    setup(() => {
-      contentStubs = [];
-      stub('gr-selection-action-box', 'placeAbove');
-      stub('gr-selection-action-box', 'placeBelow');
-      diff = element.querySelector('#diffTable');
-      builder = {
-        getContentTdByLine: sinon.stub(),
-        getContentTdByLineEl: sinon.stub(),
-        getLineElByChild,
-        getLineNumberByChild: sinon.stub(),
-        getSideByLineEl: sinon.stub(),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    teardown(() => {
-      contentStubs = null;
-      document.getSelection().removeAllRanges();
-    });
-
-    test('single first line', () => {
-      const content = stubContent(1, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('multiline starting on first line', () => {
-      const startContent = stubContent(1, 'right');
-      const endContent = stubContent(2, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('single line', () => {
-      const content = stubContent(138, 'left');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 138,
-        start_character: 5,
-        end_line: 138,
-        end_character: 12,
-      });
-      assert.equal(side, 'left');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiline', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-      assert.equal(side, 'right');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiple ranges aka firefox implementation', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-
-      const startRange = document.createRange();
-      startRange.setStart(startContent.firstChild, 10);
-      startRange.setEnd(startContent.firstChild, 11);
-
-      const endRange = document.createRange();
-      endRange.setStart(endContent.lastChild, 6);
-      endRange.setEnd(endContent.lastChild, 7);
-
-      const getRangeAtStub = sinon.stub();
-      getRangeAtStub
-          .onFirstCall().returns(startRange)
-          .onSecondCall()
-          .returns(endRange);
-      const selection = {
-        rangeCount: 2,
-        getRangeAt: getRangeAtStub,
-        removeAllRanges: sinon.stub(),
-      };
-      element._handleSelection(selection);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-    });
-
-    test('multiline grow end highlight over tabs', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 2,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('collapsed', () => {
-      const content = stubContent(138, 'left');
-      emulateSelection(content.firstChild, 5, content.firstChild, 5);
-      assert.isOk(document.getSelection().getRangeAt(0).startContainer);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.foo');
-      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 8,
-        end_line: 140,
-        end_character: 23,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.bar');
-      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 18,
-        end_line: 140,
-        end_character: 27,
-      });
-    });
-
-    test('multiple hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelectorAll('hl')[4];
-      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 2,
-        end_line: 140,
-        end_character: 61,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts outside of diff', () => {
-      const contentText = stubContent(140, 'left');
-      const contentTd = contentText.parentElement;
-
-      emulateSelection(contentTd.parentElement, 0,
-          contentText.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends outside of diff', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(content.nextElementSibling.firstChild, 2,
-          content.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts and ends on different sides', () => {
-      const startContent = stubContent(140, 'left');
-      const endContent = stubContent(130, 'right');
-      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts in comment thread element', () => {
-      const startContent = stubContent(140, 'left');
-      const comment = startContent.parentElement.querySelector(
-          '.comment-thread');
-      const endContent = stubContent(141, 'left');
-      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 83,
-        end_line: 141,
-        end_character: 4,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends in comment thread element', () => {
-      const content = stubContent(140, 'left');
-      const comment = content.parentElement.querySelector(
-          '.comment-thread');
-      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 4,
-        end_line: 140,
-        end_character: 83,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(146, 'right');
-      emulateSelection(contextControl, 0, content.firstChild, 7);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(141, 'left');
-      emulateSelection(content.firstChild, 2, contextControl, 1);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('selection containing context element', () => {
-      const startContent = stubContent(130, 'right');
-      const endContent = stubContent(146, 'right');
-      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 130,
-        start_character: 3,
-        end_line: 146,
-        end_character: 14,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('ends at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.firstChild, 1, content.querySelector('span'), 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 1,
-        end_line: 140,
-        end_character: 51,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1].nextSibling, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 51,
-        end_line: 140,
-        end_character: 71,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('properly accounts for syntax highlighting', () => {
-      const content = stubContent(140, 'left');
-      const spy = sinon.spy(element, '_normalizeRange');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1], 0);
-      const spyCall = spy.getCall(0);
-      const range = document.getSelection().getRangeAt(0);
-      assert.notDeepEqual(spyCall.returnValue, range);
-    });
-
-    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-      let content = stubContent(140, 'left');
-      let child = content.lastChild.lastChild;
-      let result = _getTextOffset(content, child);
-      assert.equal(result, 75);
-      content = stubContent(146, 'right');
-      child = content.lastChild;
-      result = _getTextOffset(content, child);
-      assert.equal(result, 0);
-    });
-
-    test('_fixTripleClickSelection', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 0,
-        end_line: 119,
-        end_character: element._getLength(startContent),
-      });
-      assert.equal(side, 'right');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..b819754
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,713 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-highlight';
+import {_getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html} from '@open-wc/testing-helpers';
+import {
+  GrDiffHighlight,
+  DiffBuilderInterface,
+  CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../gr-diff/gr-diff-utils';
+import {waitQueryAndAssert, waitUntil} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+  <table id="diffTable">
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+        <td class="right lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="138"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="119"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="140"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+          [Yet another random diff thread content here]
+        </div></td>
+        <td class="right lineNum" data-value="120"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="141"></td>
+        <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+        <td class="right lineNum" data-value="130"></td>
+        <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section contextControl">
+      <tr
+        class="diff-row side-by-side"
+        left-type="contextControl"
+        right-type="contextControl"
+      >
+        <td class="left contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+        <td class="right contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta total">
+      <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+        <td class="left"></td>
+        <td class="blank"></td>
+        <td class="right lineNum" data-value="146"></td>
+        <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="165"></td>
+        <td class="content both"><div class="contentText"></div></td>
+        <td class="right lineNum" data-value="147"></td>
+        <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+      </tr>
+    </tbody>
+  </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  suite('comment events', () => {
+    let threadEl: GrDiffThreadElement;
+    let hlRange: HTMLElement;
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+      threadEl = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      threadEl.className = 'comment-thread';
+      threadEl.rootId = 'id314';
+      diff.appendChild(threadEl);
+    });
+
+    teardown(() => {
+      element.cleanup();
+      threadEl.remove();
+    });
+
+    test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseenter', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+      hlRange.classList.add('rangeHoverHighlight');
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseleave', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+      element.selectedRange = {
+        side: Side.LEFT,
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+      diff.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      diff.dispatchEvent(requestEvent);
+      if (!createRangeEvent!) assert.fail('event not set');
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(removeActionBoxStub.called);
+    });
+  });
+
+  suite('selection', () => {
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+    let contentStubs;
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      contentStubs = [];
+      stub('gr-selection-action-box', 'placeAbove');
+      stub('gr-selection-action-box', 'placeBelow');
+    });
+
+    teardown(() => {
+      fixtureCleanup();
+      element.cleanup();
+      contentStubs = null;
+      document.getSelection()!.removeAllRanges();
+    });
+
+    const stubContent = (line: number, side: Side) => {
+      const contentTd = diff.querySelector(
+        `.${side}.lineNum[data-value="${line}"] ~ .content`
+      );
+      if (!contentTd) assert.fail('content td not found');
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl =
+        diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+        undefined;
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      return contentText;
+    };
+
+    const emulateSelection = (
+      startNode: Node,
+      startOffset: number,
+      endNode: Node,
+      endOffset: number
+    ) => {
+      const selection = document.getSelection();
+      if (!selection) assert.fail('no selection');
+      selection.removeAllRanges();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element.handleSelection(selection, false);
+    };
+
+    test('single first line', () => {
+      const content = stubContent(1, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, Side.RIGHT);
+      const endContent = stubContent(2, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', async () => {
+      const content = stubContent(138, Side.LEFT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+        diff,
+        'gr-selection-action-box'
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(side, Side.LEFT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiline', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+      assert.equal(side, Side.RIGHT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+        .onFirstCall()
+        .returns(startRange)
+        .onSecondCall()
+        .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      } as unknown as Selection;
+      element.handleSelection(selection, false);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('collapsed', () => {
+      const content = stubContent(138, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      const sel = document.getSelection();
+      if (!sel) assert.fail('no selection');
+      assert.isOk(sel.getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) {
+        assert.fail('content not found');
+      }
+      const hl = content.querySelector('.foo');
+      if (!hl?.firstChild) {
+        assert.fail('first child of hl element not found');
+      }
+      if (!hl?.nextSibling) {
+        assert.fail('next sibling of hl element not found');
+      }
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      const hl = content.querySelector('.bar');
+      if (!hl) assert.fail('hl inside content not found');
+      if (!hl.previousSibling) assert.fail('previous sibling not found');
+      if (!hl.firstChild) assert.fail('first child not found');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
+      });
+    });
+
+    test('multiple hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('first child not found');
+      const hl = content.querySelectorAll('hl')[4];
+      if (!hl) assert.fail('hl not found');
+      if (!hl.firstChild) assert.fail('first child of hl not found');
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, Side.LEFT);
+      if (!contentText) assert.fail('content not found');
+      if (!contentText.firstChild) assert.fail('child not found');
+      const contentTd = contentText.parentElement;
+      if (!contentTd) assert.fail('content td not found');
+      if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+      emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('child not found');
+      if (!content.nextElementSibling) assert.fail('sibling not found');
+      if (!content.nextElementSibling.firstChild) {
+        assert.fail('sibling child not found');
+      }
+      emulateSelection(
+        content.nextElementSibling.firstChild,
+        2,
+        content.firstChild,
+        2
+      );
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      const endContent = stubContent(130, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      if (!startContent?.parentElement) {
+        assert.fail('parent el of start content not found');
+      }
+      const comment =
+        startContent.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment not found');
+      }
+      const endContent = stubContent(141, Side.LEFT);
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      if (!content?.parentElement) {
+        assert.fail('parent element of content not found');
+      }
+      const comment = content.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment element not found');
+      }
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) assert.fail('context control not found');
+      const content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('content child not found');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) {
+        assert.fail('context control element not found');
+      }
+      const content = stubContent(141, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, Side.RIGHT);
+      const endContent = stubContent(146, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('ends at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      const span = content.querySelector('span');
+      if (!span) assert.fail('span element not found');
+      emulateSelection(content.firstChild, 1, span, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1].nextSibling!,
+        1
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1],
+        0
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 69,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+      let content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      if (!content.lastChild) assert.fail('last child of content not found');
+      let child = content.lastChild.lastChild;
+      if (!child) assert.fail('last child of last child of content not found');
+      let result = _getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content element not found');
+      child = content.lastChild;
+      if (!child) assert.fail('child element not found');
+      result = _getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('fixTripleClickSelection', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent) assert.fail('end content not found');
+      if (!endContent.firstChild) assert.fail('first child not found');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element.getLength(startContent),
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 32a3282..1068a8d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -375,15 +375,15 @@
       color === this.backgroundColor && !this.checkerboardSelected;
     return html`
       <div
-        class="${classMap({
+        class=${classMap({
           'color-picker-button': true,
           selected,
-        })}"
+        })}
       >
         <paper-icon-button
           class="color"
-          style="${styleMap({backgroundColor: color})}"
-          @click="${colorPicked}"
+          style=${styleMap({backgroundColor: color})}
+          @click=${colorPicked}
         ></paper-icon-button>
       </div>
     `;
@@ -392,14 +392,14 @@
   private renderCheckerboardButton() {
     return html`
       <div
-        class="${classMap({
+        class=${classMap({
           'color-picker-button': true,
           selected: this.checkerboardSelected,
-        })}"
+        })}
       >
         <paper-icon-button
           class="color checkerboard"
-          @click="${this.pickCheckerboard}"
+          @click=${this.pickCheckerboard}
         >
         </paper-icon-button>
       </div>
@@ -412,14 +412,14 @@
     const sourceImage = html`
       <img
         id="source-image"
-        src="${src}"
-        class="${classMap({checkerboard: this.checkerboardSelected})}"
-        style="${styleMap({
+        src=${src}
+        class=${classMap({checkerboard: this.checkerboardSelected})}
+        style=${styleMap({
           backgroundColor: this.checkerboardSelected
             ? ''
             : this.backgroundColor,
-        })}"
-        @load="${this.updateSizes}"
+        })}
+        @load=${this.updateSizes}
       />
     `;
 
@@ -428,15 +428,15 @@
         ${sourceImage}
         <img
           id="highlight-image"
-          style="${styleMap({
+          style=${styleMap({
             opacity: this.showHighlight ? '1' : '0',
             // When the highlight layer is not being shown, saving the image or
             // opening it in a new tab from the context menu, e.g. for external
             // comparison, should give back the source image, not the highlight
             // layer.
             'pointer-events': this.showHighlight ? 'auto' : 'none',
-          })}"
-          src="${ifDefined(this.diffHighlightSrc)}"
+          })}
+          src=${ifDefined(this.diffHighlightSrc)}
         />
       </div>
     `;
@@ -461,17 +461,14 @@
     };
     const versionToggle = html`
       <div id="version-switcher">
-        <paper-button
-          class="${classMap(leftClasses)}"
-          @click="${this.selectBase}"
-        >
+        <paper-button class=${classMap(leftClasses)} @click=${this.selectBase}>
           Base
         </paper-button>
-        <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.manualBlink}">
+        <paper-fab mini icon="gr-icons:swapHoriz" @click=${this.manualBlink}>
         </paper-fab>
         <paper-button
-          class="${classMap(rightClasses)}"
-          @click="${this.selectRevision}"
+          class=${classMap(rightClasses)}
+          @click=${this.selectRevision}
         >
           Revision
         </paper-button>
@@ -486,8 +483,8 @@
       ? html`
           <paper-checkbox
             id="highlight-changes"
-            ?checked="${this.showHighlight}"
-            @change="${this.showHighlightChanged}"
+            ?checked=${this.showHighlight}
+            @change=${this.showHighlightChanged}
           >
             Highlight differences
           </paper-checkbox>
@@ -496,17 +493,17 @@
 
     const overviewImage = html`
       <gr-overview-image
-        .frameRect="${this.overviewFrame}"
-        @center-updated="${this.onOverviewCenterUpdated}"
+        .frameRect=${this.overviewFrame}
+        @center-updated=${this.onOverviewCenterUpdated}
       >
         <img
-          src="${src}"
-          class="${classMap({checkerboard: this.checkerboardSelected})}"
-          style="${styleMap({
+          src=${src}
+          class=${classMap({checkerboard: this.checkerboardSelected})}
+          style=${styleMap({
             backgroundColor: this.checkerboardSelected
               ? ''
               : this.backgroundColor,
-          })}"
+          })}
         />
       </gr-overview-image>
     `;
@@ -516,12 +513,12 @@
         <paper-listbox
           slot="dropdown-content"
           selected="fit"
-          .attrForSelected="${'value'}"
-          @selected-changed="${this.zoomControlChanged}"
+          .attrForSelected=${'value'}
+          @selected-changed=${this.zoomControlChanged}
         >
           ${this.zoomLevels.map(
             zoomLevel => html`
-              <paper-item value="${zoomLevel}">
+              <paper-item value=${zoomLevel}>
                 ${zoomLevel === 'fit' ? 'Fit' : `${zoomLevel * 100}%`}
               </paper-item>
             `
@@ -533,8 +530,8 @@
     const followMouse = html`
       <paper-checkbox
         id="follow-mouse"
-        ?checked="${this.followMouse}"
-        @change="${this.followMouseChanged}"
+        ?checked=${this.followMouse}
+        @change=${this.followMouseChanged}
       >
         Magnifier follows mouse
       </paper-checkbox>
@@ -566,20 +563,20 @@
     const spacer = html`
       <div
         id="spacer"
-        style="${styleMap({
+        style=${styleMap({
           width: `${spacerWidth}px`,
           height: `${spacerHeight}px`,
-        })}"
+        })}
       ></div>
     `;
 
     const automaticBlink = html`
       <paper-fab
         id="automatic-blink-button"
-        class="${classMap({show: this.automaticBlinkShown})}"
+        class=${classMap({show: this.automaticBlinkShown})}
         title="Automatic blink"
         icon="gr-icons:${this.automaticBlink ? 'pause' : 'playArrow'}"
-        @click="${this.toggleAutomaticBlink}"
+        @click=${this.toggleAutomaticBlink}
       >
       </paper-fab>
     `;
@@ -609,25 +606,25 @@
       ${customStyle}
       <div
         class="imageArea"
-        @mousemove="${this.mousemoveImageArea}"
-        @mouseleave="${this.mouseleaveImageArea}"
+        @mousemove=${this.mousemoveImageArea}
+        @mouseleave=${this.mouseleaveImageArea}
       >
         <gr-zoomed-image
-          class="${classMap({
+          class=${classMap({
             base: this.baseSelected,
             revision: !this.baseSelected,
-          })}"
-          style="${styleMap({
+          })}
+          style=${styleMap({
             ...this.zoomedImageStyle,
             cursor: this.grabbing ? 'grabbing' : 'pointer',
-          })}"
-          .scale="${this.scale}"
-          .frameRect="${this.magnifierFrame}"
-          @mousedown="${this.mousedownMagnifier}"
-          @mouseup="${this.mouseupMagnifier}"
-          @mousemove="${this.mousemoveMagnifier}"
-          @mouseleave="${this.mouseleaveMagnifier}"
-          @dragstart="${this.dragstartMagnifier}"
+          })}
+          .scale=${this.scale}
+          .frameRect=${this.magnifierFrame}
+          @mousedown=${this.mousedownMagnifier}
+          @mouseup=${this.mouseupMagnifier}
+          @mousemove=${this.mousemoveMagnifier}
+          @mouseleave=${this.mouseleaveMagnifier}
+          @dragstart=${this.dragstartMagnifier}
         >
           ${sourceImageWithHighlight}
         </gr-zoomed-image>
@@ -889,7 +886,7 @@
   }
 
   private handleFollowMouse(event: MouseEvent) {
-    const rect = this.imageArea!.getBoundingClientRect();
+    const rect = this.imageArea.getBoundingClientRect();
     const offsetX = event.clientX - rect.left;
     const offsetY = event.clientY - rect.top;
     const fractionX = offsetX / rect.width;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 28e6d82..49a0eb5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -124,26 +124,26 @@
       <div class="content-box">
         <div
           class="content"
-          style="${styleMap({
+          style=${styleMap({
             ...this.contentStyle,
-          })}"
-          @mousemove="${this.maybeDragFrame}"
+          })}
+          @mousemove=${this.maybeDragFrame}
           @mousedown=${this.clickOverview}
-          @mouseup="${this.releaseFrame}"
+          @mouseup=${this.releaseFrame}
         >
           <div
             class="content-transform"
-            style="${styleMap(this.contentTransformStyle)}"
+            style=${styleMap(this.contentTransformStyle)}
           >
             <slot></slot>
           </div>
           <div
             class="frame"
-            style="${styleMap({
+            style=${styleMap({
               ...this.frameStyle,
               cursor: this.dragging ? 'grabbing' : 'grab',
-            })}"
-            @mousedown="${this.grabFrame}"
+            })}
+            @mousedown=${this.grabFrame}
           ></div>
         </div>
       </div>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
index 66d4671..6fdec67 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -59,7 +59,7 @@
   override render() {
     return html`
       <div id="clip">
-        <div id="transform" style="${styleMap(this.imageStyles)}">
+        <div id="transform" style=${styleMap(this.imageStyles)}>
           <slot></slot>
         </div>
       </div>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 022dbb9..1b53d05 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -17,39 +17,31 @@
 import {Subscription} from 'rxjs';
 import '@polymer/iron-icon/iron-icon';
 import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import '../../../styles/shared-styles';
 import '../../../elements/shared/gr-button/gr-button';
 import {DiffViewMode} from '../../../constants/constants';
-import {htmlTemplate} from './gr-diff-mode-selector_html';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {getAppContext} from '../../../services/app-context';
 import {fireIronAnnounce} from '../../../utils/event-util';
 import {browserModelToken} from '../../../models/browser/browser-model';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 @customElement('gr-diff-mode-selector')
-export class GrDiffModeSelector extends DIPolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, notify: true})
-  mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
-
+export class GrDiffModeSelector extends LitElement {
   /**
    * If set to true, the user's preference will be updated every time a
    * button is tapped. Don't set to true if there is no user.
    */
-  @property({type: Boolean})
-  saveOnChange = false;
+  @property({type: Boolean}) saveOnChange = false;
 
-  @property({type: Boolean})
-  showTooltipBelow = false;
+  @property({type: Boolean}) showTooltipBelow = false;
 
-  // Private but accessed by tests.
-  readonly getBrowserModel = resolve(this, browserModelToken);
+  @state() private mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  private readonly getBrowserModel = resolve(this, browserModelToken);
 
   private readonly userModel = getAppContext().userModel;
 
@@ -79,18 +71,70 @@
     super.disconnectedCallback();
   }
 
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        /* Used to remove horizontal whitespace between the icons. */
+        display: flex;
+      }
+      gr-button.selected iron-icon {
+        color: var(--link-color);
+      }
+      iron-icon {
+        height: 1.3rem;
+        width: 1.3rem;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-tooltip-content
+        has-tooltip
+        title="Side-by-side diff"
+        ?position-below=${this.showTooltipBelow}
+      >
+        <gr-button
+          id="sideBySideBtn"
+          link
+          class=${this.computeSideBySideSelected()}
+          aria-pressed=${this.isSideBySideSelected()}
+          @click=${this.handleSideBySideTap}
+        >
+          <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
+      <gr-tooltip-content
+        has-tooltip
+        ?position-below=${this.showTooltipBelow}
+        title="Unified diff"
+      >
+        <gr-button
+          id="unifiedBtn"
+          link
+          class=${this.computeUnifiedSelected()}
+          aria-pressed=${this.isUnifiedSelected()}
+          @click=${this.handleUnifiedTap}
+        >
+          <iron-icon icon="gr-icons:unified"></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
   /**
    * Set the mode. If save on change is enabled also update the preference.
    */
-  setMode(newMode: DiffViewMode) {
+  private setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
       this.userModel.updatePreferences({diff_view: newMode});
     }
     this.mode = newMode;
     let announcement;
-    if (this.isUnifiedSelected(newMode)) {
+    if (this.isUnifiedSelected()) {
       announcement = 'Changed diff view to unified';
-    } else if (this.isSideBySideSelected(newMode)) {
+    } else if (this.isSideBySideSelected()) {
       announcement = 'Changed diff view to side by side';
     }
     if (announcement) {
@@ -98,27 +142,27 @@
     }
   }
 
-  _computeSideBySideSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
+  private computeSideBySideSelected() {
+    return this.mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
   }
 
-  _computeUnifiedSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.UNIFIED ? 'selected' : '';
+  private computeUnifiedSelected() {
+    return this.mode === DiffViewMode.UNIFIED ? 'selected' : '';
   }
 
-  isSideBySideSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.SIDE_BY_SIDE;
+  private isSideBySideSelected() {
+    return this.mode === DiffViewMode.SIDE_BY_SIDE;
   }
 
-  isUnifiedSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.UNIFIED;
+  private isUnifiedSelected() {
+    return this.mode === DiffViewMode.UNIFIED;
   }
 
-  _handleSideBySideTap() {
+  private handleSideBySideTap() {
     this.setMode(DiffViewMode.SIDE_BY_SIDE);
   }
 
-  _handleUnifiedTap() {
+  private handleUnifiedTap() {
     this.setMode(DiffViewMode.UNIFIED);
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
deleted file mode 100644
index 8a6d95d..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      /* Used to remove horizontal whitespace between the icons. */
-      display: flex;
-    }
-    gr-button.selected iron-icon {
-      color: var(--link-color);
-    }
-    iron-icon {
-      height: 1.3rem;
-      width: 1.3rem;
-    }
-  </style>
-  <gr-tooltip-content
-    has-tooltip=""
-    title="Side-by-side diff"
-    position-below="[[showTooltipBelow]]"
-  >
-    <gr-button
-      id="sideBySideBtn"
-      link=""
-      class$="[[_computeSideBySideSelected(mode)]]"
-      aria-pressed$="[[isSideBySideSelected(mode)]]"
-      on-click="_handleSideBySideTap"
-    >
-      <iron-icon icon="gr-icons:side-by-side"></iron-icon>
-    </gr-button>
-  </gr-tooltip-content>
-  <gr-tooltip-content
-    has-tooltip=""
-    position-below="[[showTooltipBelow]]"
-    title="Unified diff"
-  >
-    <gr-button
-      id="unifiedBtn"
-      link=""
-      class$="[[_computeUnifiedSelected(mode)]]"
-      aria-pressed$="[[isUnifiedSelected(mode)]]"
-      on-click="_handleUnifiedTap"
-    >
-      <iron-icon icon="gr-icons:unified"></iron-icon>
-    </gr-button>
-  </gr-tooltip-content>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 3ade907..6ba5533 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -19,57 +19,151 @@
 import './gr-diff-mode-selector';
 import {GrDiffModeSelector} from './gr-diff-mode-selector';
 import {DiffViewMode} from '../../../constants/constants';
-import {stubUsers} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-diff-mode-selector');
+import {
+  queryAndAssert,
+  stubUsers,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {
+  BrowserModel,
+  browserModelToken,
+} from '../../../models/browser/browser-model';
+import {getAppContext} from '../../../services/app-context';
+import {UserModel} from '../../../models/user/user-model';
+import {createPreferences} from '../../../test/test-data-generators';
+import {GrButton} from '../../../elements/shared/gr-button/gr-button';
 
 suite('gr-diff-mode-selector tests', () => {
   let element: GrDiffModeSelector;
+  let browserModel: BrowserModel;
+  let userModel: UserModel;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    userModel = getAppContext().userModel;
+    browserModel = new BrowserModel(userModel);
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-diff-mode-selector></gr-diff-mode-selector>`,
+          browserModelToken,
+          browserModel
+        )
+      )
+    ).querySelector('gr-diff-mode-selector')!;
   });
 
-  test('_computeSelectedClass', () => {
-    assert.equal(
-      element._computeSideBySideSelected(DiffViewMode.SIDE_BY_SIDE),
-      'selected'
+  test('renders side-by-side selected', async () => {
+    userModel.setPreferences({
+      ...createPreferences(),
+      diff_view: DiffViewMode.SIDE_BY_SIDE,
+    });
+    await waitUntilObserved(
+      browserModel.diffViewMode$,
+      mode => mode === DiffViewMode.SIDE_BY_SIDE
     );
-    assert.equal(element._computeSideBySideSelected(DiffViewMode.UNIFIED), '');
-    assert.equal(
-      element._computeUnifiedSelected(DiffViewMode.UNIFIED),
-      'selected'
-    );
-    assert.equal(
-      element._computeUnifiedSelected(DiffViewMode.SIDE_BY_SIDE),
-      ''
-    );
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-tooltip-content has-tooltip="" title="Side-by-side diff">
+        <gr-button
+          id="sideBySideBtn"
+          link=""
+          class="selected"
+          aria-disabled="false"
+          aria-pressed="true"
+          role="button"
+          tabindex="0"
+        >
+          <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
+      <gr-tooltip-content has-tooltip title="Unified diff">
+        <gr-button
+          id="unifiedBtn"
+          link=""
+          role="button"
+          aria-disabled="false"
+          aria-pressed="false"
+          tabindex="0"
+        >
+          <iron-icon icon="gr-icons:unified"></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
+    `);
   });
 
-  test('setMode', () => {
-    element.getBrowserModel().setScreenWidth(0);
+  test('renders unified selected', async () => {
+    userModel.setPreferences({
+      ...createPreferences(),
+      diff_view: DiffViewMode.UNIFIED,
+    });
+    await waitUntilObserved(
+      browserModel.diffViewMode$,
+      mode => mode === DiffViewMode.UNIFIED
+    );
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-tooltip-content has-tooltip="" title="Side-by-side diff">
+        <gr-button
+          id="sideBySideBtn"
+          link=""
+          class=""
+          aria-disabled="false"
+          aria-pressed="false"
+          role="button"
+          tabindex="0"
+        >
+          <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
+      <gr-tooltip-content has-tooltip title="Unified diff">
+        <gr-button
+          id="unifiedBtn"
+          link=""
+          class="selected"
+          role="button"
+          aria-disabled="false"
+          aria-pressed="true"
+          tabindex="0"
+        >
+          <iron-icon icon="gr-icons:unified"></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
+    `);
+  });
+
+  test('set mode', async () => {
+    browserModel.setScreenWidth(0);
     const saveStub = stubUsers('updatePreferences');
 
-    flush();
     // Setting the mode initially does not save prefs.
     element.saveOnChange = true;
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
     assert.isFalse(saveStub.called);
 
     // Setting the mode to itself does not save prefs.
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
     assert.isFalse(saveStub.called);
 
     // Setting the mode to something else does not save prefs if saveOnChange
     // is false.
     element.saveOnChange = false;
-    element.setMode(DiffViewMode.UNIFIED);
+    queryAndAssert<GrButton>(element, 'gr-button#unifiedBtn').click();
+    await element.updateComplete;
+
     assert.isFalse(saveStub.called);
 
     // Setting the mode to something else does not save prefs if saveOnChange
     // is false.
     element.saveOnChange = true;
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
     assert.isTrue(saveStub.calledOnce);
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index b588bc7..6f71c65 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
   GrDiffLine,
   GrDiffLineType,
@@ -27,16 +15,17 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property} from '@polymer/decorators';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {RenderPreferences} from '../../../api/diff';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const WHOLE_FILE = -1;
 
-interface State {
+// visible for testing
+export interface State {
   lineNums: {
     left: number;
     right: number;
@@ -59,7 +48,7 @@
  * into a series of chunks that are this size at most.
  *
  * Note: The value of 120 is chosen so that it is larger than the default
- * _asyncThreshold of 64, but feel free to tune this constant to your
+ * asyncThreshold of 64, but feel free to tune this constant to your
  * performance needs.
  */
 function calcMaxGroupSize(asyncThreshold?: number): number {
@@ -67,6 +56,12 @@
   return asyncThreshold * 2;
 }
 
+/** Interface for listening to the output of the processor. */
+export interface GroupConsumer {
+  addGroup(group: GrDiffGroup): void;
+  clearGroups(): void;
+}
+
 /**
  * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
  *
@@ -92,55 +87,29 @@
  *    that the part that is within the context or has comments is shown, while
  *    the rest is not.
  */
-@customElement('gr-diff-processor')
-export class GrDiffProcessor extends PolymerElement {
-  @property({type: Number})
+export class GrDiffProcessor {
   context = 3;
 
-  /**
-   * The builder elements watches this (two-way data binding and @observe) and
-   * thus passes each added group on to the renderer (i.e. gr-diff-builder).
-   * You must only add to this array and not modify it later (only when
-   * resetting). The source of truth is then held by gr-diff-builder, which also
-   * reflects expanding and collapsing of groups.
-   */
-  @property({type: Array, notify: true})
-  groups: GrDiffGroup[] = [];
+  consumer?: GroupConsumer;
 
-  @property({type: Object})
   keyLocations: KeyLocations = {left: {}, right: {}};
 
-  @property({type: Number})
-  _asyncThreshold = 64;
+  private asyncThreshold = 64;
 
-  @property({type: Number})
-  _nextStepHandle: number | null = null;
+  private nextStepHandle: number | null = null;
 
-  @property({type: Object})
-  _processPromise: CancelablePromise<void> | null = null;
+  private processPromise: CancelablePromise<void> | null = null;
 
-  @property({type: Boolean})
-  _isScrolling?: boolean;
+  // visible for testing
+  isScrolling?: boolean;
 
   private resetIsScrollingTask?: DelayedTask;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    window.addEventListener('scroll', this.handleWindowScroll);
-  }
-
-  override disconnectedCallback() {
-    this.resetIsScrollingTask?.cancel();
-    this.cancel();
-    window.removeEventListener('scroll', this.handleWindowScroll);
-    super.disconnectedCallback();
-  }
-
   private readonly handleWindowScroll = () => {
-    this._isScrolling = true;
+    this.isScrolling = true;
     this.resetIsScrollingTask = debounce(
       this.resetIsScrollingTask,
-      () => (this._isScrolling = false),
+      () => (this.isScrolling = false),
       50
     );
   };
@@ -156,10 +125,12 @@
     // Cancel any still running process() calls, because they append to the
     // same groups field.
     this.cancel();
+    window.addEventListener('scroll', this.handleWindowScroll);
 
-    this.groups = [];
-    this.push('groups', this._makeGroup('LOST'));
-    this.push('groups', this._makeGroup(FILE));
+    assertIsDefined(this.consumer, 'consumer');
+    this.consumer.clearGroups();
+    this.consumer.addGroup(this.makeGroup('LOST'));
+    this.consumer.addGroup(this.makeGroup(FILE));
 
     // If it's a binary diff, we won't be rendering hunks of text differences
     // so finish processing.
@@ -167,33 +138,34 @@
       return Promise.resolve();
     }
 
-    this._processPromise = util.makeCancelable(
+    this.processPromise = makeCancelable(
       new Promise(resolve => {
         const state = {
           lineNums: {left: 0, right: 0},
           chunkIndex: 0,
         };
 
-        chunks = this._splitLargeChunks(chunks);
-        chunks = this._splitCommonChunksWithKeyLocations(chunks);
+        chunks = this.splitLargeChunks(chunks);
+        chunks = this.splitCommonChunksWithKeyLocations(chunks);
 
         let currentBatch = 0;
         const nextStep = () => {
-          if (this._isScrolling) {
-            this._nextStepHandle = window.setTimeout(nextStep, 100);
+          if (this.isScrolling) {
+            this.nextStepHandle = window.setTimeout(nextStep, 100);
             return;
           }
           // If we are done, resolve the promise.
           if (state.chunkIndex >= chunks.length) {
             resolve();
-            this._nextStepHandle = null;
+            this.nextStepHandle = null;
             return;
           }
 
           // Process the next chunk and incorporate the result.
-          const stateUpdate = this._processNext(state, chunks);
+          const stateUpdate = this.processNext(state, chunks);
           for (const group of stateUpdate.groups) {
-            this.push('groups', group);
+            assertIsDefined(this.consumer, 'consumer');
+            this.consumer.addGroup(group);
             currentBatch += group.lines.length;
           }
           state.lineNums.left += stateUpdate.lineDelta.left;
@@ -201,9 +173,9 @@
 
           // Increment the index and recurse.
           state.chunkIndex = stateUpdate.newChunkIndex;
-          if (currentBatch >= this._asyncThreshold) {
+          if (currentBatch >= this.asyncThreshold) {
             currentBatch = 0;
-            this._nextStepHandle = window.setTimeout(nextStep, 1);
+            this.nextStepHandle = window.setTimeout(nextStep, 1);
           } else {
             nextStep.call(this);
           }
@@ -212,8 +184,9 @@
         nextStep.call(this);
       })
     );
-    return this._processPromise.finally(() => {
-      this._processPromise = null;
+    return this.processPromise.finally(() => {
+      this.processPromise = null;
+      window.removeEventListener('scroll', this.handleWindowScroll);
     });
   }
 
@@ -221,20 +194,22 @@
    * Cancel any jobs that are running.
    */
   cancel() {
-    if (this._nextStepHandle !== null) {
-      window.clearTimeout(this._nextStepHandle);
-      this._nextStepHandle = null;
+    if (this.nextStepHandle !== null) {
+      window.clearTimeout(this.nextStepHandle);
+      this.nextStepHandle = null;
     }
-    if (this._processPromise) {
-      this._processPromise.cancel();
+    if (this.processPromise) {
+      this.processPromise.cancel();
     }
+    window.removeEventListener('scroll', this.handleWindowScroll);
   }
 
   /**
    * Process the next uncollapsible chunk, or the next collapsible chunks.
    */
-  _processNext(state: State, chunks: DiffContent[]) {
-    const firstUncollapsibleChunkIndex = this._firstUncollapsibleChunkIndex(
+  // visible for testing
+  processNext(state: State, chunks: DiffContent[]) {
+    const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
       chunks,
       state.chunkIndex
     );
@@ -242,11 +217,11 @@
       const chunk = chunks[state.chunkIndex];
       return {
         lineDelta: {
-          left: this._linesLeft(chunk).length,
-          right: this._linesRight(chunk).length,
+          left: this.linesLeft(chunk).length,
+          right: this.linesRight(chunk).length,
         },
         groups: [
-          this._chunkToGroup(
+          this.chunkToGroup(
             chunk,
             state.lineNums.left + 1,
             state.lineNums.right + 1
@@ -256,33 +231,33 @@
       };
     }
 
-    return this._processCollapsibleChunks(
+    return this.processCollapsibleChunks(
       state,
       chunks,
       firstUncollapsibleChunkIndex
     );
   }
 
-  _linesLeft(chunk: DiffContent) {
+  private linesLeft(chunk: DiffContent) {
     return chunk.ab || chunk.a || [];
   }
 
-  _linesRight(chunk: DiffContent) {
+  private linesRight(chunk: DiffContent) {
     return chunk.ab || chunk.b || [];
   }
 
-  _firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
     let chunkIndex = offset;
     while (
       chunkIndex < chunks.length &&
-      this._isCollapsibleChunk(chunks[chunkIndex])
+      this.isCollapsibleChunk(chunks[chunkIndex])
     ) {
       chunkIndex++;
     }
     return chunkIndex;
   }
 
-  _isCollapsibleChunk(chunk: DiffContent) {
+  private isCollapsibleChunk(chunk: DiffContent) {
     return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
   }
 
@@ -296,7 +271,7 @@
    * 3) Visible context after the hidden common code, unless it's the very
    * end of the file.
    */
-  _processCollapsibleChunks(
+  private processCollapsibleChunks(
     state: State,
     chunks: DiffContent[],
     firstUncollapsibleChunkIndex: number
@@ -306,11 +281,11 @@
       firstUncollapsibleChunkIndex
     );
     const lineCount = collapsibleChunks.reduce(
-      (sum, chunk) => sum + this._commonChunkLength(chunk),
+      (sum, chunk) => sum + this.commonChunkLength(chunk),
       0
     );
 
-    let groups = this._chunksToGroups(
+    let groups = this.chunksToGroups(
       collapsibleChunks,
       state.lineNums.left + 1,
       state.lineNums.right + 1
@@ -336,7 +311,7 @@
     };
   }
 
-  _commonChunkLength(chunk: DiffContent) {
+  private commonChunkLength(chunk: DiffContent) {
     if (chunk.skip) {
       return chunk.skip;
     }
@@ -347,31 +322,31 @@
       'common chunk needs same number of a and b lines: ',
       chunk
     );
-    return this._linesLeft(chunk).length;
+    return this.linesLeft(chunk).length;
   }
 
-  _chunksToGroups(
+  private chunksToGroups(
     chunks: DiffContent[],
     offsetLeft: number,
     offsetRight: number
   ): GrDiffGroup[] {
     return chunks.map(chunk => {
-      const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
-      const chunkLength = this._commonChunkLength(chunk);
+      const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this.commonChunkLength(chunk);
       offsetLeft += chunkLength;
       offsetRight += chunkLength;
       return group;
     });
   }
 
-  _chunkToGroup(
+  private chunkToGroup(
     chunk: DiffContent,
     offsetLeft: number,
     offsetRight: number
   ): GrDiffGroup {
     const type =
       chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
-    const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
+    const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
     const options = {
       moveDetails: chunk.move_details,
       dueToRebase: !!chunk.due_to_rebase,
@@ -395,10 +370,14 @@
     }
   }
 
-  _linesFromChunk(chunk: DiffContent, offsetLeft: number, offsetRight: number) {
+  private linesFromChunk(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
     if (chunk.ab) {
       return chunk.ab.map((row, i) =>
-        this._lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+        this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
       );
     }
     let lines: GrDiffLine[] = [];
@@ -406,7 +385,7 @@
       // Avoiding a.push(...b) because that causes callstack overflows for
       // large b, which can occur when large files are added removed.
       lines = lines.concat(
-        this._linesFromRows(
+        this.linesFromRows(
           GrDiffLineType.REMOVE,
           chunk.a,
           offsetLeft,
@@ -418,7 +397,7 @@
       // Avoiding a.push(...b) because that causes callstack overflows for
       // large b, which can occur when large files are added removed.
       lines = lines.concat(
-        this._linesFromRows(
+        this.linesFromRows(
           GrDiffLineType.ADD,
           chunk.b,
           offsetRight,
@@ -429,21 +408,22 @@
     return lines;
   }
 
-  _linesFromRows(
+  // visible for testing
+  linesFromRows(
     lineType: GrDiffLineType,
     rows: string[],
     offset: number,
     intralineInfos?: number[][]
   ): GrDiffLine[] {
     const grDiffHighlights = intralineInfos
-      ? this._convertIntralineInfos(rows, intralineInfos)
+      ? this.convertIntralineInfos(rows, intralineInfos)
       : undefined;
     return rows.map((row, i) =>
-      this._lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+      this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
     );
   }
 
-  _lineFromRow(
+  private lineFromRow(
     type: GrDiffLineType,
     offsetLeft: number,
     offsetRight: number,
@@ -464,7 +444,7 @@
     return line;
   }
 
-  _makeGroup(number: LineNumber) {
+  private makeGroup(number: LineNumber) {
     const line = new GrDiffLine(GrDiffLineType.BOTH);
     line.beforeNumber = number;
     line.afterNumber = number;
@@ -486,12 +466,13 @@
    * @param chunks Chunks as returned from the server
    * @return Finer grained chunks.
    */
-  _splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
+  // visible for testing
+  splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
     const newChunks = [];
 
     for (const chunk of chunks) {
       if (!chunk.ab) {
-        for (const subChunk of this._breakdownChunk(chunk)) {
+        for (const subChunk of this.breakdownChunk(chunk)) {
           newChunks.push(subChunk);
         }
         continue;
@@ -501,7 +482,7 @@
       // chunks so they can be rendered incrementally. Note: this is not
       // enabled for any other context preference because manipulating the
       // chunks in this way violates assumptions by the context grouper logic.
-      const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
+      const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
       if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
         // Split large shared chunks in two, where the first is the maximum
         // group size.
@@ -522,7 +503,8 @@
    * @param chunks DiffContents as returned from server.
    * @return Finer grained DiffContents.
    */
-  _splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+  // visible for testing
+  splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
     const result = [];
     let leftLineNum = 1;
     let rightLineNum = 1;
@@ -545,8 +527,8 @@
           'DiffContent with common=true must always have equal length'
         );
       }
-      const numLines = this._commonChunkLength(chunk);
-      const chunkEnds = this._findChunkEndsAtKeyLocations(
+      const numLines = this.commonChunkLength(chunk);
+      const chunkEnds = this.findChunkEndsAtKeyLocations(
         numLines,
         leftLineNum,
         rightLineNum
@@ -562,7 +544,7 @@
         });
       } else if (chunk.ab) {
         result.push(
-          ...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
+          ...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
             ({lines, keyLocation}) => {
               return {
                 ...chunk,
@@ -573,8 +555,8 @@
           )
         );
       } else if (chunk.common) {
-        const aChunks = this._splitAtChunkEnds(chunk.a!, chunkEnds);
-        const bChunks = this._splitAtChunkEnds(chunk.b!, chunkEnds);
+        const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
+        const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
         result.push(
           ...aChunks.map(({lines, keyLocation}, i) => {
             return {
@@ -595,7 +577,7 @@
    * @return Offsets of the new chunk ends, including whether it's a key
    * location.
    */
-  _findChunkEndsAtKeyLocations(
+  private findChunkEndsAtKeyLocations(
     numLines: number,
     leftOffset: number,
     rightOffset: number
@@ -628,7 +610,7 @@
     return result;
   }
 
-  _splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
+  private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
     const result = [];
     let lastChunkEndOffset = 0;
     for (const {offset, keyLocation} of chunkEnds) {
@@ -645,7 +627,8 @@
    * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
    * for rendering.
    */
-  _convertIntralineInfos(
+  // visible for testing
+  convertIntralineInfos(
     rows: string[],
     intralineInfos: number[][]
   ): Highlights[] {
@@ -695,7 +678,8 @@
    * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
    * or a delta it is returned as the single element of the result array.
    */
-  _breakdownChunk(chunk: DiffContent): DiffContent[] {
+  // visible for testing
+  breakdownChunk(chunk: DiffContent): DiffContent[] {
     let key: 'a' | 'b' | 'ab' | null = null;
     const {a, b, ab, move_details} = chunk;
     if (a?.length && !b?.length) {
@@ -712,8 +696,8 @@
       return [chunk];
     }
 
-    const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
-    return this._breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
+    const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+    return this.breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
       const subChunk: DiffContent = {};
       subChunk[key!] = subChunkLines;
       if (chunk.due_to_rebase) {
@@ -730,7 +714,8 @@
    * Given an array and a size, return an array of arrays where no inner array
    * is larger than that size, preserving the original order.
    */
-  _breakdown<T>(array: T[], size: number): T[][] {
+  // visible for testing
+  breakdown<T>(array: T[], size: number): T[][] {
     if (!array.length) {
       return [];
     }
@@ -741,18 +726,12 @@
     const head = array.slice(0, array.length - size);
     const tail = array.slice(array.length - size);
 
-    return this._breakdown(head, size).concat([tail]);
+    return this.breakdown(head, size).concat([tail]);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
     if (renderPrefs.num_lines_rendered_at_once) {
-      this._asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+      this.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
     }
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-processor': GrDiffProcessor;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
similarity index 65%
rename from polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index bebdf34..60a1cba 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -1,66 +1,52 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import 'lodash/lodash.js';
-import './gr-diff-processor.js';
-import {GrDiffLineType, FILE} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-
-const basicFixture = fixtureFromElement('gr-diff-processor');
+import '../../../test/common-test-setup-karma';
+import './gr-diff-processor';
+import {GrDiffLineType, FILE, GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffProcessor, State} from './gr-diff-processor';
+import {DiffContent} from '../../../types/diff';
 
 suite('gr-diff-processor tests', () => {
   const WHOLE_FILE = -1;
   const loremIpsum =
-      'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
-      'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
-      'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
-      'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
-      'fugit assum per.';
+    'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+    'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+    'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+    'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+    'fugit assum per.';
 
-  let element;
+  let element: GrDiffProcessor;
+  let groups: GrDiffGroup[];
 
-  setup(() => {
-
-  });
+  setup(() => {});
 
   suite('not logged in', () => {
     setup(() => {
-      element = basicFixture.instantiate();
-
+      groups = [];
+      element = new GrDiffProcessor();
+      element.consumer = {
+        addGroup(group: GrDiffGroup) {
+          groups.push(group);
+        },
+        clearGroups() {
+          groups = [];
+        },
+      };
       element.context = 4;
     });
 
     test('process loaded content', () => {
-      const content = [
+      const content: DiffContent[] = [
         {
-          ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
-          ],
+          ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
         },
         {
-          a: [
-            '  Welcome ',
-            '  to the wooorld of tomorrow!',
-          ],
-          b: [
-            '  Hello, world!',
-          ],
+          a: ['  Welcome ', '  to the wooorld of tomorrow!'],
+          b: ['  Hello, world!'],
         },
         {
           ab: [
@@ -71,8 +57,7 @@
         },
       ];
 
-      return element.process(content).then(() => {
-        const groups = element.groups;
+      return element.process(content, false).then(() => {
         groups.shift(); // remove portedThreadsWithoutRangeGroup
         assert.equal(groups.length, 4);
 
@@ -87,9 +72,15 @@
         assert.equal(group.type, GrDiffGroupType.BOTH);
         assert.equal(group.lines.length, 2);
 
-        function beforeNumberFn(l) { return l.beforeNumber; }
-        function afterNumberFn(l) { return l.afterNumber; }
-        function textFn(l) { return l.text; }
+        function beforeNumberFn(l: GrDiffLine) {
+          return l.beforeNumber;
+        }
+        function afterNumberFn(l: GrDiffLine) {
+          return l.afterNumber;
+        }
+        function textFn(l: GrDiffLine) {
+          return l.text;
+        }
 
         assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
         assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
@@ -109,9 +100,7 @@
           '  Welcome ',
           '  to the wooorld of tomorrow!',
         ]);
-        assert.deepEqual(group.adds.map(textFn), [
-          '  Hello, world!',
-        ]);
+        assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
 
         group = groups[3];
         assert.equal(group.type, GrDiffGroupType.BOTH);
@@ -127,12 +116,9 @@
     });
 
     test('first group is for file', () => {
-      const content = [
-        {b: ['foo']},
-      ];
+      const content = [{b: ['foo']}];
 
-      return element.process(content).then(() => {
-        const groups = element.groups;
+      return element.process(content, false).then(() => {
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         assert.equal(groups[0].type, GrDiffGroupType.BOTH);
@@ -147,13 +133,15 @@
       test('at the beginning, larger than context', () => {
         element.context = 10;
         const content = [
-          {ab: new Array(100)
-              .fill('all work and no play make jack a dull boy')},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
-          const groups = element.groups;
+        return element.process(content, false).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -176,17 +164,14 @@
       test('at the beginning with skip chunks', async () => {
         element.context = 10;
         const content = [
-          {ab: new Array(20)
-              .fill('all work and no play make jack a dull boy')},
+          {ab: new Array(20).fill('all work and no play make jack a dull boy')},
           {skip: 43900},
-          {ab: new Array(30)
-              .fill('some other content')},
+          {ab: new Array(30).fill('some other content')},
           {a: ['some other content']},
         ];
 
-        await element.process(content);
+        await element.process(content, false);
 
-        const groups = element.groups;
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         // group[0] is the file group
@@ -227,13 +212,11 @@
       test('at the beginning, smaller than context', () => {
         element.context = 10;
         const content = [
-          {ab: new Array(5)
-              .fill('all work and no play make jack a dull boy')},
+          {ab: new Array(5).fill('all work and no play make jack a dull boy')},
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
-          const groups = element.groups;
+        return element.process(content, false).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -250,12 +233,14 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
         ];
 
-        return element.process(content).then(() => {
-          const groups = element.groups;
+        return element.process(content, false).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -264,16 +249,14 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 10);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
           assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
           assert.equal(groups[3].contextGroups[0].lines.length, 90);
           for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -282,12 +265,10 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
         ];
 
-        return element.process(content).then(() => {
-          const groups = element.groups;
+        return element.process(content, false).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -296,8 +277,7 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 5);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -306,30 +286,26 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
           {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
+            a: new Array(3).fill('all work and no play make jill a dull girl'),
             b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
+              '  all work and no play make jill a dull girl'
+            ),
             common: true,
           },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
           {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
+            a: new Array(3).fill('all work and no play make jill a dull girl'),
             b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
+              '  all work and no play make jill a dull girl'
+            ),
             common: true,
           },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
         ];
 
-        return element.process(content).then(() => {
-          const groups = element.groups;
+        return element.process(content, false).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -341,8 +317,7 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 3);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[3].type, GrDiffGroupType.DELTA);
@@ -350,19 +325,19 @@
           assert.equal(groups[3].adds.length, 3);
           assert.equal(groups[3].removes.length, 3);
           for (const l of groups[3].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
           for (const l of groups[3].adds) {
             assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
           }
 
           assert.equal(groups[4].type, GrDiffGroupType.BOTH);
           assert.equal(groups[4].lines.length, 3);
           for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           // The next chunk is partially shown, so it results in two groups
@@ -372,12 +347,13 @@
           assert.equal(groups[5].adds.length, 1);
           assert.equal(groups[5].removes.length, 1);
           for (const l of groups[5].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
           for (const l of groups[5].adds) {
             assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
           }
 
           assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
@@ -387,22 +363,20 @@
           assert.equal(groups[6].contextGroups[0].removes.length, 2);
           assert.equal(groups[6].contextGroups[0].adds.length, 2);
           for (const l of groups[6].contextGroups[0].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
           for (const l of groups[6].contextGroups[0].adds) {
             assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
           }
 
           // The final chunk is completely hidden
-          assert.equal(
-              groups[6].contextGroups[1].type,
-              GrDiffGroupType.BOTH);
+          assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
           assert.equal(groups[6].contextGroups[1].lines.length, 3);
           for (const l of groups[6].contextGroups[1].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -411,13 +385,15 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
-          const groups = element.groups;
+        return element.process(content, false).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -426,23 +402,20 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 10);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
           assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
           assert.equal(groups[3].contextGroups[0].lines.length, 80);
           for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[4].type, GrDiffGroupType.BOTH);
           assert.equal(groups[4].lines.length, 10);
           for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -451,13 +424,11 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
-          const groups = element.groups;
+        return element.process(content, false).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -466,8 +437,7 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 5);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -477,17 +447,14 @@
       element.context = 10;
       const content = [
         {a: ['all work and no play make andybons a dull boy']},
-        {ab: new Array(20)
-            .fill('all work and no play make jill a dull girl')},
+        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
         {skip: 60},
-        {ab: new Array(20)
-            .fill('all work and no play make jill a dull girl')},
+        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
         {a: ['all work and no play make andybons a dull boy']},
       ];
 
-      await element.process(content);
+      await element.process(content, false);
 
-      const groups = element.groups;
       groups.shift(); // remove portedThreadsWithoutRangeGroup
 
       // group[0] is the file group
@@ -501,8 +468,7 @@
       assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
       assert.equal(commonGroup.contextGroups[0].lines.length, 10);
       for (const l of commonGroup.contextGroups[0].lines) {
-        assert.equal(
-            l.text, 'all work and no play make jill a dull girl');
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
       }
 
       // Skipped group
@@ -517,8 +483,7 @@
       // Hidden context after
       assert.equal(commonGroup.contextGroups[2].lines.length, 10);
       for (const l of commonGroup.contextGroups[2].lines) {
-        assert.equal(
-            l.text, 'all work and no play make jill a dull girl');
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
       }
       // group[4] is the displayed part of the second ab
     });
@@ -536,7 +501,7 @@
             '',
             'Licensed under the Apache License, Version 2.0 (the "License");',
             'you may not use this file except in compliance with the ' +
-                'License.',
+              'License.',
             'You may obtain a copy of the License at',
             '',
             'http://www.apache.org/licenses/LICENSE-2.0',
@@ -546,12 +511,11 @@
             '"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.',
+              'License.',
           ],
         },
       ];
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
+      const result = element.splitCommonChunksWithKeyLocations(content);
       assert.deepEqual(result, [
         {
           ab: ['Copyright (C) 2015 The Android Open Source Project'],
@@ -562,7 +526,7 @@
             '',
             'Licensed under the Apache License, Version 2.0 (the "License");',
             'you may not use this file except in compliance with the ' +
-                'License.',
+              'License.',
             'You may obtain a copy of the License at',
             '',
             'http://www.apache.org/licenses/LICENSE-2.0',
@@ -572,8 +536,7 @@
           keyLocation: false,
         },
         {
-          ab: [
-            'software distributed under the License is distributed on an '],
+          ab: ['software distributed under the License is distributed on an '],
           keyLocation: true,
         },
         {
@@ -581,7 +544,7 @@
             '"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.',
+              'License.',
           ],
           keyLocation: false,
         },
@@ -591,11 +554,12 @@
     test('breaks down shared chunks w/ whole-file', () => {
       const maxGroupSize = 128;
       const size = maxGroupSize * 2 + 5;
-      const content = [{
-        ab: _.times(size, () => `${Math.random()}`),
-      }];
+      const ab = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
       element.context = -1;
-      const result = element._splitLargeChunks(content);
+      const result = element.splitLargeChunks(content);
       assert.equal(result.length, 2);
       assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
       assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
@@ -604,10 +568,13 @@
     test('breaks down added chunks', () => {
       const maxGroupSize = 128;
       const size = maxGroupSize * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
       element.context = 5;
-      const splitContent = element._splitLargeChunks([{a: [], b: content}])
-          .map(r => r.b);
+      const splitContent = element
+        .splitLargeChunks([{a: [], b: content}])
+        .map(r => r.b);
       assert.equal(splitContent.length, 3);
       assert.deepEqual(splitContent[0], content.slice(0, 5));
       assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
@@ -617,10 +584,13 @@
     test('breaks down removed chunks', () => {
       const maxGroupSize = 128;
       const size = maxGroupSize * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
       element.context = 5;
-      const splitContent = element._splitLargeChunks([{a: content, b: []}])
-          .map(r => r.a);
+      const splitContent = element
+        .splitLargeChunks([{a: content, b: []}])
+        .map(r => r.a);
       assert.equal(splitContent.length, 3);
       assert.deepEqual(splitContent[0], content.slice(0, 5));
       assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
@@ -629,24 +599,30 @@
 
     test('does not break down moved chunks', () => {
       const size = 120 * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
       element.context = 5;
-      const splitContent = element._splitLargeChunks([{
-        a: content,
-        b: [],
-        move_details: {changed: false},
-      }]).map(r => r.a);
+      const splitContent = element
+        .splitLargeChunks([
+          {
+            a: content,
+            b: [],
+            move_details: {changed: false, range: {start: 1, end: 1}},
+          },
+        ])
+        .map(r => r.a);
       assert.equal(splitContent.length, 1);
       assert.deepEqual(splitContent[0], content);
     });
 
     test('does not break-down common chunks w/ context', () => {
-      const content = [{
-        ab: _.times(75, () => `${Math.random()}`),
-      }];
+      const ab = Array(75)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
       element.context = 4;
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
+      const result = element.splitCommonChunksWithKeyLocations(content);
       assert.equal(result.length, 1);
       assert.deepEqual(result[0].ab, content[0].ab);
       assert.isFalse(result[0].keyLocation);
@@ -658,15 +634,15 @@
       let content = [
         '      <section class="summary">',
         '        <gr-linked-text content="' +
-            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
         '      </section>',
       ];
       let highlights = [
-        [31, 34], [42, 26],
+        [31, 34],
+        [42, 26],
       ];
 
-      let results = element._convertIntralineInfos(content,
-          highlights);
+      let results = element.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -687,8 +663,12 @@
           endIndex: 6,
         },
       ]);
-      const lines = element._linesFromRows(
-          GrDiffGroupType.BOTH, content, 0, highlights);
+      const lines = element.linesFromRows(
+        GrDiffLineType.BOTH,
+        content,
+        0,
+        highlights
+      );
       assert.equal(lines.length, 3);
       assert.isTrue(lines[0].hasIntralineInfo);
       assert.equal(lines[0].highlights.length, 1);
@@ -715,7 +695,7 @@
         [12, 67],
         [14, 29],
       ];
-      results = element._convertIntralineInfos(content, highlights);
+      results = element.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -746,41 +726,29 @@
     });
 
     test('scrolling pauses rendering', () => {
-      const contentRow = {
-        ab: [
-          '',
-          '',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
-      element._isScrolling = true;
-      element.process(content);
+      const content = Array(200).fill({ab: ['', '']});
+      element.isScrolling = true;
+      element.process(content, false);
       // Just the files group - no more processing during scrolling.
-      assert.equal(element.groups.length, 2);
+      assert.equal(groups.length, 2);
 
-      element._isScrolling = false;
-      element.process(content);
+      element.isScrolling = false;
+      element.process(content, false);
       // More groups have been processed. How many does not matter here.
-      assert.isAtLeast(element.groups.length, 3);
+      assert.isAtLeast(groups.length, 3);
     });
 
     test('image diffs', () => {
-      const contentRow = {
-        ab: [
-          '',
-          '',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
+      const content = Array(200).fill({ab: ['', '']});
       element.process(content, true);
-      assert.equal(element.groups.length, 2);
+      assert.equal(groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
-      assert.equal(element.groups[0].lines.length, 1);
+      assert.equal(groups[0].lines.length, 1);
     });
 
-    suite('_processNext', () => {
-      let rows;
+    suite('processNext', () => {
+      let rows: string[];
 
       setup(() => {
         rows = loremIpsum.split(' ');
@@ -788,16 +756,12 @@
 
       test('WHOLE_FILE', () => {
         element.context = WHOLE_FILE;
-        const state = {
+        const state: State = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
 
         // Results in one, uncollapsed group with all rows.
         assert.equal(result.groups.length, 1);
@@ -806,16 +770,22 @@
 
         // Line numbers are set correctly.
         assert.equal(
-            result.groups[0].lines[0].beforeNumber,
-            state.lineNums.left + 1);
+          result.groups[0].lines[0].beforeNumber,
+          state.lineNums.left + 1
+        );
         assert.equal(
-            result.groups[0].lines[0].afterNumber,
-            state.lineNums.right + 1);
+          result.groups[0].lines[0].afterNumber,
+          state.lineNums.right + 1
+        );
 
-        assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
-            state.lineNums.left + rows.length);
-        assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
-            state.lineNums.right + rows.length);
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].beforeNumber,
+          state.lineNums.left + rows.length
+        );
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].afterNumber,
+          state.lineNums.right + rows.length
+        );
       });
 
       test('WHOLE_FILE with skip chunks still get collapsed', () => {
@@ -826,13 +796,8 @@
           chunkIndex: 1,
         };
         const skip = 10000;
-        const chunks = [
-          {a: ['foo']},
-          {skip},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
         // Results in one, uncollapsed group with all rows.
         assert.equal(result.groups.length, 1);
         assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
@@ -842,31 +807,27 @@
         const [skippedGroup, abGroup] = result.groups[0].contextGroups;
 
         // Line numbers are set correctly.
-        assert.deepEqual(
-            skippedGroup.lineRange,
-            {
-              left: {
-                start_line: lineNums.left + 1,
-                end_line: lineNums.left + skip,
-              },
-              right: {
-                start_line: lineNums.right + 1,
-                end_line: lineNums.right + skip,
-              },
-            });
+        assert.deepEqual(skippedGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + 1,
+            end_line: lineNums.left + skip,
+          },
+          right: {
+            start_line: lineNums.right + 1,
+            end_line: lineNums.right + skip,
+          },
+        });
 
-        assert.deepEqual(
-            abGroup.lineRange,
-            {
-              left: {
-                start_line: lineNums.left + skip + 1,
-                end_line: lineNums.left + skip + rows.length,
-              },
-              right: {
-                start_line: lineNums.right + skip + 1,
-                end_line: lineNums.right + skip + rows.length,
-              },
-            });
+        assert.deepEqual(abGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + skip + 1,
+            end_line: lineNums.left + skip + rows.length,
+          },
+          right: {
+            start_line: lineNums.right + skip + 1,
+            end_line: lineNums.right + skip + rows.length,
+          },
+        });
       });
 
       test('with context', () => {
@@ -875,12 +836,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
         const expectedCollapseSize = rows.length - 2 * element.context;
 
         assert.equal(result.groups.length, 3, 'Results in three groups');
@@ -891,8 +848,10 @@
         assert.equal(result.groups[2].lines.length, element.context);
 
         // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[1].contextGroups[0].lines.length,
-            expectedCollapseSize);
+        assert.equal(
+          result.groups[1].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
       });
 
       test('first', () => {
@@ -901,12 +860,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 0,
         };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
         const expectedCollapseSize = rows.length - element.context;
 
         assert.equal(result.groups.length, 2, 'Results in two groups');
@@ -915,8 +870,10 @@
         assert.equal(result.groups[1].lines.length, element.context);
 
         // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[0].contextGroups[0].lines.length,
-            expectedCollapseSize);
+        assert.equal(
+          result.groups[0].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
       });
 
       test('few-rows', () => {
@@ -927,12 +884,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 0,
         };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -946,12 +899,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -959,40 +908,39 @@
       });
 
       suite('with key location', () => {
-        let state;
-        let chunks;
+        let state: State;
+        let chunks: DiffContent[];
 
         setup(() => {
           state = {
             lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
           };
           element.context = 10;
-          chunks = [
-            {ab: rows},
-            {ab: ['foo'], keyLocation: true},
-            {ab: rows},
-          ];
+          chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
         });
 
         test('context before', () => {
           state.chunkIndex = 0;
-          const result = element._processNext(state, chunks);
+          const result = element.processNext(state, chunks);
 
           // The first chunk is split into two groups:
           // 1) A context-control, hiding everything but the context before
           //    the key location.
           // 2) The context before the key location.
-          // The key location is not processed in this call to _processNext
+          // The key location is not processed in this call to processNext
           assert.equal(result.groups.length, 2);
           // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[0].contextGroups[0].lines.length,
-              rows.length - element.context);
+          assert.equal(
+            result.groups[0].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
           assert.equal(result.groups[1].lines.length, element.context);
         });
 
         test('key location itself', () => {
           state.chunkIndex = 1;
-          const result = element._processNext(state, chunks);
+          const result = element.processNext(state, chunks);
 
           // The second chunk results in a single group, that is just the
           // line with the key location
@@ -1004,7 +952,7 @@
 
         test('context after', () => {
           state.chunkIndex = 2;
-          const result = element._processNext(state, chunks);
+          const result = element.processNext(state, chunks);
 
           // The last chunk is split into two groups:
           // 1) The context after the key location.
@@ -1013,109 +961,113 @@
           assert.equal(result.groups.length, 2);
           assert.equal(result.groups[0].lines.length, element.context);
           // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[1].contextGroups[0].lines.length,
-              rows.length - element.context);
+          assert.equal(
+            result.groups[1].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
         });
       });
     });
 
     suite('gr-diff-processor helpers', () => {
-      let rows;
+      let rows: string[];
 
       setup(() => {
         rows = loremIpsum.split(' ');
       });
 
-      test('_linesFromRows', () => {
+      test('linesFromRows', () => {
         const startLineNum = 10;
-        let result = element._linesFromRows(GrDiffLineType.ADD, rows,
-            startLineNum + 1);
+        let result = element.linesFromRows(
+          GrDiffLineType.ADD,
+          rows,
+          startLineNum + 1
+        );
 
         assert.equal(result.length, rows.length);
         assert.equal(result[0].type, GrDiffLineType.ADD);
         assert.notOk(result[0].hasIntralineInfo);
         assert.equal(result[0].afterNumber, startLineNum + 1);
         assert.notOk(result[0].beforeNumber);
-        assert.equal(result[result.length - 1].afterNumber,
-            startLineNum + rows.length);
+        assert.equal(
+          result[result.length - 1].afterNumber,
+          startLineNum + rows.length
+        );
         assert.notOk(result[result.length - 1].beforeNumber);
 
-        result = element._linesFromRows(GrDiffLineType.REMOVE, rows,
-            startLineNum + 1);
+        result = element.linesFromRows(
+          GrDiffLineType.REMOVE,
+          rows,
+          startLineNum + 1
+        );
 
         assert.equal(result.length, rows.length);
         assert.equal(result[0].type, GrDiffLineType.REMOVE);
         assert.notOk(result[0].hasIntralineInfo);
         assert.equal(result[0].beforeNumber, startLineNum + 1);
         assert.notOk(result[0].afterNumber);
-        assert.equal(result[result.length - 1].beforeNumber,
-            startLineNum + rows.length);
+        assert.equal(
+          result[result.length - 1].beforeNumber,
+          startLineNum + rows.length
+        );
         assert.notOk(result[result.length - 1].afterNumber);
       });
     });
 
-    suite('_breakdown*', () => {
-      test('_breakdownChunk breaks down additions', () => {
-        sinon.spy(element, '_breakdown');
+    suite('breakdown*', () => {
+      test('breakdownChunk breaks down additions', () => {
+        const breakdownSpy = sinon.spy(element, 'breakdown');
         const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = element._breakdownChunk(chunk);
+        const result = element.breakdownChunk(chunk);
         assert.deepEqual(result, [chunk]);
-        assert.isTrue(element._breakdown.called);
+        assert.isTrue(breakdownSpy.called);
       });
 
-      test('_breakdownChunk keeps due_to_rebase for broken down additions',
-          () => {
-            sinon.spy(element, '_breakdown');
-            const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-            const result = element._breakdownChunk(chunk);
-            for (const subResult of result) {
-              assert.isTrue(subResult.due_to_rebase);
-            }
-          });
+      test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
+        sinon.spy(element, 'breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+        const result = element.breakdownChunk(chunk);
+        for (const subResult of result) {
+          assert.isTrue(subResult.due_to_rebase);
+        }
+      });
 
-      test('_breakdown common case', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
+      test('breakdown common case', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
         const size = 3;
 
-        const result = element._breakdown(array, size);
+        const result = element.breakdown(array, size);
 
         for (const subResult of result) {
           assert.isAtMost(subResult.length, size);
         }
-        const flattened = result
-            .reduce((a, b) => a.concat(b), []);
+        const flattened = result.reduce((a, b) => a.concat(b), []);
         assert.deepEqual(flattened, array);
       });
 
-      test('_breakdown smaller than size', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
+      test('breakdown smaller than size', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
         const size = 10;
         const expected = [array];
 
-        const result = element._breakdown(array, size);
+        const result = element.breakdown(array, size);
 
         assert.deepEqual(result, expected);
       });
 
-      test('_breakdown empty', () => {
-        const array = [];
+      test('breakdown empty', () => {
+        const array: string[] = [];
         const size = 10;
-        const expected = [];
+        const expected: string[][] = [];
 
-        const result = element._breakdown(array, size);
+        const result = element.breakdown(array, size);
 
         assert.deepEqual(result, expected);
       });
     });
   });
-
-  test('detaching cancels', () => {
-    element = basicFixture.instantiate();
-    sinon.stub(element, 'cancel');
-    element.disconnectedCallback();
-    assert(element.cancel.called);
-  });
 });
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index 2665ef0..4bb8cc3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -1,39 +1,23 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
-import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-selection_html';
 import {
   normalize,
   NormalizedRange,
 } from '../gr-diff-highlight/gr-range-normalizer';
 import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {
   getLineElByChild,
   getSide,
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
+import {assertIsDefined} from '../../../utils/common-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -55,49 +39,35 @@
   return {left: null, right: null};
 }
 
-@customElement('gr-diff-selection')
-export class GrDiffSelection extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
+export class GrDiffSelection {
+  // visible for testing
   diff?: DiffInfo;
 
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
+  // visible for testing
+  diffTable?: HTMLElement;
 
-  @property({type: Object})
-  _linesCache: LinesCache = {left: null, right: null};
+  // visible for testing
+  linesCache: LinesCache = getNewCache();
 
-  constructor() {
-    super();
-    this.addEventListener('copy', e => this._handleCopy(e));
-    addListener(this, 'down', e => this._handleDown(e));
+  init(diff: DiffInfo, diffTable: HTMLElement) {
+    this.cleanup();
+    this.diff = diff;
+    this.diffTable = diffTable;
+    this.diffTable.classList.add(SelectionClass.RIGHT);
+    this.diffTable.addEventListener('copy', this.handleCopy);
+    this.diffTable.addEventListener('mousedown', this.handleDown);
+    this.linesCache = getNewCache();
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.classList.add(SelectionClass.RIGHT);
+  cleanup() {
+    if (!this.diffTable) return;
+    this.diffTable.removeEventListener('copy', this.handleCopy);
+    this.diffTable.removeEventListener('mousedown', this.handleDown);
   }
 
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  @observe('diff')
-  _diffChanged() {
-    this._linesCache = getNewCache();
-  }
-
-  _handleDownOnRangeComment(node: Element) {
+  handleDownOnRangeComment(node: Element) {
     if (isThreadEl(node)) {
-      this._setClasses([
+      this.setClasses([
         SelectionClass.COMMENT,
         getSide(node) === Side.LEFT
           ? SelectionClass.LEFT
@@ -108,14 +78,13 @@
     return false;
   }
 
-  _handleDown(e: Event) {
+  handleDown = (e: Event) => {
     const target = e.target;
     if (!(target instanceof Element)) return;
-    // Handle the down event on comment thread in Polymer 2
-    const handled = this._handleDownOnRangeComment(target);
+    const handled = this.handleDownOnRangeComment(target);
     if (handled) return;
     const lineEl = getLineElByChild(target);
-    const blameSelected = this._elementDescendedFromClass(target, 'blame');
+    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
     if (!lineEl && !blameSelected) {
       return;
     }
@@ -125,9 +94,10 @@
     if (blameSelected) {
       targetClasses.push(SelectionClass.BLAME);
     } else if (lineEl) {
-      const commentSelected = this._elementDescendedFromClass(
+      const commentSelected = descendedFromClass(
         target,
-        'gr-comment'
+        'gr-comment',
+        this.diffTable
       );
       const side = getSideByLineEl(lineEl);
 
@@ -140,60 +110,50 @@
       }
     }
 
-    this._setClasses(targetClasses);
-  }
+    this.setClasses(targetClasses);
+  };
 
   /**
    * Set the provided list of classes on the element, to the exclusion of all
    * other SelectionClass values.
    */
-  _setClasses(targetClasses: string[]) {
+  setClasses(targetClasses: string[]) {
+    if (!this.diffTable) return;
     // Remove any selection classes that do not belong.
     for (const className of Object.values(SelectionClass)) {
       if (!targetClasses.includes(className)) {
-        this.classList.remove(className);
+        this.diffTable.classList.remove(className);
       }
     }
     // Add new selection classes iff they are not already present.
-    for (const _class of targetClasses) {
-      if (!this.classList.contains(_class)) {
-        this.classList.add(_class);
+    for (const targetClass of targetClasses) {
+      if (!this.diffTable.classList.contains(targetClass)) {
+        this.diffTable.classList.add(targetClass);
       }
     }
   }
 
-  _getCopyEventTarget(e: Event) {
-    return (dom(e) as EventApi).rootTarget;
-  }
-
-  /**
-   * Utility function to determine whether an element is a descendant of
-   * another element with the particular className.
-   */
-  _elementDescendedFromClass(element: Element, className: string) {
-    return descendedFromClass(element, className, this.diffBuilder.diffElement);
-  }
-
-  _handleCopy(e: ClipboardEvent) {
+  handleCopy = (e: ClipboardEvent) => {
     let commentSelected = false;
-    const target = this._getCopyEventTarget(e);
+    const target = e.composedPath()[0];
     if (!(target instanceof Element)) return;
     if (target instanceof HTMLTextAreaElement) return;
-    if (!this._elementDescendedFromClass(target, 'diff-row')) return;
-    if (this.classList.contains(SelectionClass.COMMENT)) {
+    if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+    if (!this.diffTable) return;
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) {
       commentSelected = true;
     }
     const lineEl = getLineElByChild(target);
     if (!lineEl) return;
     const side = getSideByLineEl(lineEl);
-    const text = this._getSelectedText(side, commentSelected);
+    const text = this.getSelectedText(side, commentSelected);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
       e.preventDefault();
     }
-  }
+  };
 
-  _getSelection() {
+  getSelection() {
     const diffHosts = querySelectorAll(document.body, 'gr-diff');
     if (!diffHosts.length) return document.getSelection();
 
@@ -219,13 +179,13 @@
    * @param commentSelected Whether or not a comment is selected.
    * @return The selected text.
    */
-  _getSelectedText(side: Side, commentSelected: boolean) {
-    const sel = this._getSelection();
+  getSelectedText(side: Side, commentSelected: boolean) {
+    const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
     }
     if (commentSelected) {
-      return this._getCommentLines(sel, side);
+      return this.getCommentLines(sel, side);
     }
     const range = normalize(sel.getRangeAt(0));
     const startLineEl = getLineElByChild(range.startContainer);
@@ -250,7 +210,7 @@
       if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
-    return this._getRangeFromDiff(
+    return this.getRangeFromDiff(
       startLineNum,
       range.startOffset,
       endLineNum,
@@ -262,7 +222,7 @@
   /**
    * Query the diff object for the selected lines.
    */
-  _getRangeFromDiff(
+  getRangeFromDiff(
     startLineNum: number,
     startOffset: number,
     endLineNum: number | undefined,
@@ -274,7 +234,7 @@
       startLineNum -= skipChunk.skip!;
       if (endLineNum) endLineNum -= skipChunk.skip!;
     }
-    const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
     if (lines.length) {
       lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
       lines[0] = lines[0].substring(startOffset);
@@ -288,9 +248,9 @@
    * @param side The side that is currently selected.
    * @return An array of strings indexed by line number.
    */
-  _getDiffLines(side: Side): string[] {
-    if (this._linesCache[side]) {
-      return this._linesCache[side]!;
+  getDiffLines(side: Side): string[] {
+    if (this.linesCache[side]) {
+      return this.linesCache[side]!;
     }
     if (!this.diff) return [];
     let lines: string[] = [];
@@ -303,7 +263,7 @@
         lines = lines.concat(chunk.b);
       }
     }
-    this._linesCache[side] = lines;
+    this.linesCache[side] = lines;
     return lines;
   }
 
@@ -315,11 +275,11 @@
    * @param side The side that is currently selected.
    * @return The selected comment text.
    */
-  _getCommentLines(sel: Selection, side: Side) {
+  getCommentLines(sel: Selection, side: Side) {
     const range = normalize(sel.getRangeAt(0));
     const content = [];
-    // Query the diffElement for comments.
-    const messages = this.diffBuilder.diffElement.querySelectorAll(
+    assertIsDefined(this.diffTable, 'diffTable');
+    const messages = this.diffTable.querySelectorAll(
       `.side-by-side [data-side="${side}"] .message *, .unified .message *`
     );
 
@@ -339,9 +299,9 @@
 
         if (
           el.id === 'output' &&
-          !this._elementDescendedFromClass(el, 'collapsed')
+          !descendedFromClass(el, 'collapsed', this.diffTable)
         ) {
-          content.push(this._getTextContentForRange(el, sel, range));
+          content.push(this.getTextContentForRange(el, sel, range));
         }
       }
     }
@@ -359,7 +319,7 @@
    * @param range The normalized selection range.
    * @return The text within the selection.
    */
-  _getTextContentForRange(
+  getTextContentForRange(
     domNode: Node,
     sel: Selection,
     range: NormalizedRange
@@ -379,15 +339,9 @@
       }
     } else {
       for (const childNode of domNode.childNodes) {
-        text += this._getTextContentForRange(childNode, sel, range);
+        text += this.getTextContentForRange(childNode, sel, range);
       }
     }
     return text;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-selection': GrDiffSelection;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.js
deleted file mode 100644
index 15454f9..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.js
+++ /dev/null
@@ -1,389 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-selection.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<gr-diff-selection>
-      <table id="diffTable" class="side-by-side">
-        <tr class="diff-row">
-          <td class="blame" data-line-number="1"></td>
-          <td class="lineNum left" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ba ba</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="2"></td>
-          <td class="lineNum left" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="left">zin</div>
-          </td>
-          <td class="lineNum right" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="right">more more more</div>
-            <div data-side="right">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
-                </div>
-              </div>
-            </div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="3"></td>
-          <td class="lineNum left" data-value="3">3</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="3">3</td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="4"></td>
-          <td class="lineNum left" data-value="4">4</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <textarea data-side="right">test for textarea copying</textarea>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="4">4</td>
-        </tr>
-        <tr class="not-diff-row">
-          <td class="other">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-      </table>
-    </gr-diff-selection>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-selection', () => {
-  let element;
-
-  const emulateCopyOn = function(target) {
-    const fakeEvent = {
-      target,
-      preventDefault: sinon.stub(),
-      clipboardData: {
-        setData: sinon.stub(),
-      },
-    };
-    element._getCopyEventTarget.returns(target);
-    element._handleCopy(fakeEvent);
-    return fakeEvent;
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    sinon.stub(element, '_getCopyEventTarget');
-    element._cachedDiffBuilder = {
-      getLineElByChild: sinon.stub().returns({}),
-      getSideByLineEl: sinon.stub(),
-      diffElement: element.querySelector('#diffTable'),
-    };
-    element.diff = {
-      content: [
-        {
-          a: ['ba ba'],
-          b: ['some other text'],
-        },
-        {
-          a: ['zin'],
-          b: ['more more more'],
-        },
-        {
-          a: ['ga ga'],
-          b: ['some other text'],
-        },
-      ],
-    };
-  });
-
-  test('applies selected-left on left side click', () => {
-    element.classList.add('selected-right');
-    const lineNumberEl = element.querySelector('.lineNum.left');
-    MockInteractions.down(lineNumberEl);
-    assert.isTrue(
-        element.classList.contains('selected-left'), 'adds selected-left');
-    assert.isFalse(
-        element.classList.contains('selected-right'),
-        'removes selected-right');
-  });
-
-  test('applies selected-right on right side click', () => {
-    element.classList.add('selected-left');
-    const lineNumberEl = element.querySelector('.lineNum.right');
-    MockInteractions.down(lineNumberEl);
-    assert.isTrue(
-        element.classList.contains('selected-right'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('applies selected-blame on blame click', () => {
-    element.classList.add('selected-left');
-    element.diffBuilder.getLineElByChild.returns(null);
-    sinon.stub(element, '_elementDescendedFromClass').callsFake(
-        (el, className) => className === 'blame');
-    MockInteractions.down(element);
-    assert.isTrue(
-        element.classList.contains('selected-blame'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('ignores copy for non-content Element', () => {
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('.not-diff-row'));
-    assert.isFalse(element._getSelectedText.called);
-  });
-
-  test('asks for text for left side Elements', () => {
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
-  });
-
-  test('reacts to copy for content Elements', () => {
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(element._getSelectedText.called);
-  });
-
-  test('copy event is prevented for content Elements', () => {
-    sinon.stub(element, '_getSelectedText');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    element._getSelectedText.returns('test');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(event.preventDefault.called);
-  });
-
-  test('inserts text into clipboard on copy', () => {
-    sinon.stub(element, '_getSelectedText').returns('the text');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(
-        ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
-  });
-
-  test('_setClasses adds given SelectionClass values, removes others', () => {
-    element.classList.add('selected-right');
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(element.classList.contains('selected-comment'));
-    assert.isTrue(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isFalse(element.classList.contains('selected-blame'));
-
-    element._setClasses(['selected-blame']);
-    assert.isFalse(element.classList.contains('selected-comment'));
-    assert.isFalse(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isTrue(element.classList.contains('selected-blame'));
-  });
-
-  test('_setClasses removes before it ads', () => {
-    element.classList.add('selected-right');
-    const addStub = sinon.stub(element.classList, 'add');
-    const removeStub = sinon.stub(element.classList, 'remove').callsFake(
-        () => {
-          assert.isFalse(addStub.called);
-        });
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(addStub.called);
-    assert.isTrue(removeStub.called);
-  });
-
-  test('copies content correctly', () => {
-    // Fetch the line number.
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  test('copies comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelector('.gr-formatted-text *').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
-    selection.addRange(range);
-    assert.equal('s is a comment\nThis is a differ',
-        element._getSelectedText('left', true));
-  });
-
-  test('respects astral chars in comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    const nodes = element.querySelectorAll('.gr-formatted-text *');
-    range.setStart(nodes[2].childNodes[2], 13);
-    range.setEnd(nodes[2].childNodes[2], 23);
-    selection.addRange(range);
-    assert.equal('mment 💩 u',
-        element._getSelectedText('left', true));
-  });
-
-  test('defers to default behavior for textarea', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selectedTextSpy = sinon.spy(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('textarea'));
-    assert.isFalse(selectedTextSpy.called);
-  });
-
-  test('regression test for 4794', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-right');
-    element.classList.remove('selected-left');
-
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelectorAll('div.contentText')[1].firstChild, 4);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[1].firstChild, 10);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('right'), ' other');
-  });
-
-  test('copies to end of side (issue 7895)', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      // Return null for the end container.
-      if (child.textContent === 'ga ga') { return null; }
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  suite('_getTextContentForRange', () => {
-    let selection;
-    let range;
-    let nodes;
-
-    setup(() => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
-      selection = document.getSelection();
-      selection.removeAllRanges();
-      range = document.createRange();
-      nodes = element.querySelectorAll('.gr-formatted-text *');
-    });
-
-    test('multi level element contained in range', () => {
-      range.setStart(nodes[2].childNodes[0], 1);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'his is a differ');
-    });
-
-    test('multi level element as startContainer of range', () => {
-      range.setStart(nodes[2].childNodes[1], 0);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'a differ');
-    });
-
-    test('startContainer === endContainer', () => {
-      range.setStart(nodes[0].firstChild, 2);
-      range.setEnd(nodes[0].firstChild, 12);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'is is a co');
-    });
-  });
-
-  test('cache is reset when diff changes', () => {
-    element._linesCache = {left: 'test', right: 'test'};
-    element.diff = {};
-    flush();
-    assert.deepEqual(element._linesCache, {left: null, right: null});
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
new file mode 100644
index 0000000..b44114a
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -0,0 +1,390 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-selection';
+import {GrDiffSelection} from './gr-diff-selection';
+import {createDiff} from '../../../test/test-data-generators';
+import {DiffInfo, Side} from '../../../api/diff';
+import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mouseDown} from '../../../test/test-utils';
+
+const diffTableTemplate = html`
+  <table id="diffTable" class="side-by-side">
+    <tr class="diff-row">
+      <td class="blame" data-line-number="1"></td>
+      <td class="lineNum left" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ba ba</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text">This is a comment</span>
+            </div>
+          </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="2"></td>
+      <td class="lineNum left" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="left">zin</div>
+      </td>
+      <td class="lineNum right" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="right">more more more</div>
+        <div data-side="right">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is a comment on the right</span
+              >
+            </div>
+          </div>
+        </div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="3"></td>
+      <td class="lineNum left" data-value="3">3</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is <a>a</a> different comment 💩 unicode is fun</span
+              >
+            </div>
+          </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="3">3</td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="4"></td>
+      <td class="lineNum left" data-value="4">4</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <textarea data-side="right">test for textarea copying</textarea>
+          </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="4">4</td>
+    </tr>
+    <tr class="not-diff-row">
+      <td class="other">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+  </table>
+`;
+
+suite('gr-diff-selection', () => {
+  let element: GrDiffSelection;
+  let diffTable: HTMLTableElement;
+
+  const emulateCopyOn = function (target: HTMLElement | null) {
+    const fakeEvent = {
+      target,
+      preventDefault: sinon.stub(),
+      composedPath() {
+        return [target];
+      },
+      clipboardData: {
+        setData: sinon.stub(),
+      },
+    };
+    element.handleCopy(fakeEvent as unknown as ClipboardEvent);
+    return fakeEvent;
+  };
+
+  setup(async () => {
+    element = new GrDiffSelection();
+    diffTable = await fixture<HTMLTableElement>(diffTableTemplate);
+
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {
+          a: ['ba ba'],
+          b: ['some other text'],
+        },
+        {
+          a: ['zin'],
+          b: ['more more more'],
+        },
+        {
+          a: ['ga ga'],
+          b: ['some other text'],
+        },
+      ],
+    };
+    element.init(diff, diffTable);
+  });
+
+  test('applies selected-left on left side click', () => {
+    element.diffTable!.classList.add('selected-right');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      element.diffTable!.classList.contains('selected-left'),
+      'adds selected-left'
+    );
+    assert.isFalse(
+      element.diffTable!.classList.contains('selected-right'),
+      'removes selected-right'
+    );
+  });
+
+  test('applies selected-right on right side click', () => {
+    element.diffTable!.classList.add('selected-left');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      element.diffTable!.classList.contains('selected-right'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      element.diffTable!.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('applies selected-blame on blame click', () => {
+    element.diffTable!.classList.add('selected-left');
+    const blameDiv = document.createElement('div');
+    blameDiv.classList.add('blame');
+    element.diffTable!.appendChild(blameDiv);
+    mouseDown(blameDiv);
+    assert.isTrue(
+      element.diffTable!.classList.contains('selected-blame'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      element.diffTable!.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('ignores copy for non-content Element', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('.not-diff-row'));
+    assert.isFalse(getSelectedTextStub.called);
+  });
+
+  test('asks for text for left side Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(getSelectedTextStub.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    getSelectedTextStub.returns('test');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(event.preventDefault.called);
+  });
+
+  test('inserts text into clipboard on copy', () => {
+    sinon.stub(element, 'getSelectedText').returns('the text');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual(
+      ['Text', 'the text'],
+      event.clipboardData.setData.lastCall.args
+    );
+  });
+
+  test('setClasses adds given SelectionClass values, removes others', () => {
+    element.diffTable!.classList.add('selected-right');
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(element.diffTable!.classList.contains('selected-comment'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-blame'));
+
+    element.setClasses(['selected-blame']);
+    assert.isFalse(element.diffTable!.classList.contains('selected-comment'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-blame'));
+  });
+
+  test('setClasses removes before it ads', () => {
+    element.diffTable!.classList.add('selected-right');
+    const addStub = sinon.stub(element.diffTable!.classList, 'add');
+    const removeStub = sinon
+      .stub(element.diffTable!.classList, 'remove')
+      .callsFake(() => {
+        assert.isFalse(addStub.called);
+      });
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(addStub.called);
+    assert.isTrue(removeStub.called);
+  });
+
+  test('copies content correctly', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
+    range.setEnd(
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
+      2
+    );
+    selection.addRange(range);
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+  });
+
+  test('copies comments', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+      diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
+      3
+    );
+    range.setEnd(
+      diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
+      7
+    );
+    selection.addRange(range);
+    assert.equal(
+      's is a comment\nThis is a differ',
+      element.getSelectedText(Side.LEFT, true)
+    );
+  });
+
+  test('respects astral chars in comments', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
+    range.setStart(nodes[2].childNodes[2], 13);
+    range.setEnd(nodes[2].childNodes[2], 23);
+    selection.addRange(range);
+    assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
+  });
+
+  test('defers to default behavior for textarea', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+    const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('textarea'));
+    assert.isFalse(selectedTextSpy.called);
+  });
+
+  test('regression test for 4794', () => {
+    element.diffTable!.classList.add('selected-right');
+    element.diffTable!.classList.remove('selected-left');
+
+    const selection = document.getSelection();
+    if (!selection) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
+      4
+    );
+    range.setEnd(
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
+      10
+    );
+    selection.addRange(range);
+    assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
+  });
+
+  test('copies to end of side (issue 7895)', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
+    range.setEnd(
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
+      2
+    );
+    selection.addRange(range);
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+  });
+
+  suite('getTextContentForRange', () => {
+    let selection: Selection;
+    let range: Range;
+    let nodes: NodeListOf<GrFormattedText>;
+
+    setup(() => {
+      element.diffTable!.classList.add('selected-left');
+      element.diffTable!.classList.add('selected-comment');
+      element.diffTable!.classList.remove('selected-right');
+      const s = document.getSelection();
+      if (s === null) assert.fail('no selection');
+      selection = s;
+      selection.removeAllRanges();
+      range = document.createRange();
+      nodes = diffTable.querySelectorAll('.gr-formatted-text *');
+    });
+
+    test('multi level element contained in range', () => {
+      range.setStart(nodes[2].childNodes[0], 1);
+      range.setEnd(nodes[2].childNodes[2], 7);
+      selection.addRange(range);
+      assert.equal(
+        element.getTextContentForRange(diffTable, selection, range),
+        'his is a differ'
+      );
+    });
+
+    test('multi level element as startContainer of range', () => {
+      range.setStart(nodes[2].childNodes[1], 0);
+      range.setEnd(nodes[2].childNodes[2], 7);
+      selection.addRange(range);
+      assert.equal(
+        element.getTextContentForRange(diffTable, selection, range),
+        'a differ'
+      );
+    });
+
+    test('startContainer === endContainer', () => {
+      range.setStart(nodes[0].firstChild!, 2);
+      range.setEnd(nodes[0].firstChild!, 12);
+      selection.addRange(range);
+      assert.equal(
+        element.getTextContentForRange(diffTable, selection, range),
+        'is is a co'
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
similarity index 62%
rename from polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 321086c..43a56d1 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -1,32 +1,25 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
+import '../../../test/common-test-setup-karma';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
     const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
     const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
-    let group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-      l1, l2, l3,
-    ]});
+    let group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [l1, l2, l3],
+    });
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, [l1, l2]);
     assert.deepEqual(group.removes, [l3]);
@@ -59,7 +52,9 @@
     const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
 
     const group = new GrDiffGroup({
-      type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]});
+      type: GrDiffGroupType.BOTH,
+      lines: [l1, l2, l3],
+    });
 
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, []);
@@ -83,34 +78,44 @@
     const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH);
 
-    assert.throws(() =>
-      new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]}));
+    assert.throws(
+      () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+    );
   });
 
   suite('hideInContextControl', () => {
-    let groups;
+    let groups: GrDiffGroup[];
     setup(() => {
       groups = [
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-          new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-          new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-          new GrDiffLine(GrDiffLineType.REMOVE, 8),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
-          new GrDiffLine(GrDiffLineType.REMOVE, 9),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 11),
-          new GrDiffLine(GrDiffLineType.REMOVE, 10),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 12),
-          new GrDiffLine(GrDiffLineType.REMOVE, 11),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
-          new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
-          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
-        ]}),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+            new GrDiffLine(GrDiffLineType.REMOVE, 10),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+            new GrDiffLine(GrDiffLineType.REMOVE, 11),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+          ],
+        }),
       ];
     });
 
@@ -140,21 +145,25 @@
       assert.equal(collapsedGroups[2].contextGroups.length, 2);
 
       assert.equal(
-          collapsedGroups[2].contextGroups[0].type,
-          GrDiffGroupType.DELTA);
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].adds,
-          groups[1].adds.slice(1));
+        collapsedGroups[2].contextGroups[0].adds,
+        groups[1].adds.slice(1)
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].removes,
-          groups[1].removes.slice(1));
+        collapsedGroups[2].contextGroups[0].removes,
+        groups[1].removes.slice(1)
+      );
 
       assert.equal(
-          collapsedGroups[2].contextGroups[1].type,
-          GrDiffGroupType.BOTH);
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[1].lines,
-          [groups[2].lines[0]]);
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groups[2].lines[0],
+      ]);
 
       assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
@@ -166,19 +175,26 @@
           type: GrDiffGroupType.BOTH,
           skip: 60,
           offsetLeft: 8,
-          offsetRight: 10});
+          offsetRight: 10,
+        });
         groups = [
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+              new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+              new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+            ],
+          }),
           skipGroup,
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
-            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
-            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+              new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+              new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+            ],
+          }),
         ];
       });
 
@@ -189,13 +205,11 @@
     });
 
     test('groups unchanged if the hidden range is empty', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 0, 0), groups);
+      assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
     });
 
     test('groups unchanged if there is only 1 line to hide', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 3, 4), groups);
+      assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
     });
   });
 
@@ -206,7 +220,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.ADD));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('is total for remove', () => {
@@ -215,12 +229,12 @@
         lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('not total for empty', () => {
       const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
-      assert.isFalse(group.isTotal(group));
+      assert.isFalse(group.isTotal());
     });
 
     test('not total for non-delta', () => {
@@ -229,8 +243,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.BOTH));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isFalse(group.isTotal(group));
+      assert.isFalse(group.isTotal());
     });
   });
 });
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index e35eff5..27952d3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -43,11 +43,7 @@
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffPreferencesInfoKey,
-} from '../../../types/diff';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {
   GrDiffBuilderElement,
@@ -80,6 +76,8 @@
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {deepEqual} from '../../../utils/deep-util';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -99,8 +97,6 @@
 
 export interface GrDiff {
   $: {
-    highlights: GrDiffHighlight;
-    diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
 }
@@ -179,7 +175,7 @@
   @property({type: Object})
   highlightRange?: CommentRange;
 
-  @property({type: Array})
+  @property({type: Array, observer: '_coverageRangesObserver'})
   coverageRanges: CoverageRange[] = [];
 
   @property({type: Boolean, observer: '_lineWrappingObserver'})
@@ -248,9 +244,6 @@
   @property({type: Object, observer: '_blameChanged'})
   blame: BlameInfo[] | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
   @property({type: Boolean})
   showNewlineWarningLeft = false;
 
@@ -292,7 +285,16 @@
   @property({type: Boolean})
   isAttached = false;
 
-  private renderDiffTableTask?: DelayedTask;
+  // visible for testing
+  renderDiffTableTask?: DelayedTask;
+
+  private diffSelection = new GrDiffSelection();
+
+  // visible for testing
+  highlights = new GrDiffHighlight();
+
+  // visible for testing
+  diffBuilder = new GrDiffBuilderElement();
 
   constructor() {
     super();
@@ -315,11 +317,14 @@
     this.renderDiffTableTask?.cancel();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
+    this.diffSelection.cleanup();
+    this.highlights.cleanup();
+    this.diffBuilder.cancel();
     super.disconnectedCallback();
   }
 
   getLineNumEls(side: Side): HTMLElement[] {
-    return this.$.diffBuilder.getLineNumEls(side);
+    return this.diffBuilder.getLineNumEls(side);
   }
 
   showNoChangeMessage(
@@ -357,7 +362,7 @@
     // and pass the shadow DOM selection into gr-diff-highlight, where the
     // corresponding range is determined and normalized.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, false);
+    this.highlights.handleSelectionChange(selection, false);
   };
 
   private readonly handleMouseUp = () => {
@@ -365,7 +370,7 @@
     // mouse-up if there's a selection that just covers a line change. We
     // can't do that on selection change since the user may still be dragging.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, true);
+    this.highlights.handleSelectionChange(selection, true);
   };
 
   /** Gets the current selection, preferring the shadow DOM selection. */
@@ -404,7 +409,7 @@
       const range = getRange(threadEl);
       if (!range) return undefined;
 
-      return {side, range, hovering: false, rootId: threadEl.rootId};
+      return {side, range, rootId: threadEl.rootId};
     }
 
     // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
@@ -420,20 +425,25 @@
           cr.side === removedCommentRange.side &&
           rangesEqual(cr.range, removedCommentRange.range)
       );
-      this.splice('_commentRanges', i, 1);
+      this._commentRanges.splice(i, 1);
     }
 
-    if (addedCommentRanges && addedCommentRanges.length) {
-      this.push('_commentRanges', ...addedCommentRanges);
+    if (addedCommentRanges?.length) {
+      this._commentRanges.push(...addedCommentRanges);
     }
     if (this.highlightRange) {
-      this.push('_commentRanges', {
+      this._commentRanges.push({
         side: Side.RIGHT,
         range: this.highlightRange,
-        hovering: true,
         rootId: '',
       });
     }
+
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+  }
+
+  _coverageRangesObserver() {
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
   }
 
   /**
@@ -478,21 +488,27 @@
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.$.diffBuilder.cancel();
+    this.diffBuilder.cancel();
     this.renderDiffTableTask?.cancel();
   }
 
   getCursorStops(): Array<HTMLElement | AbortStop> {
     if (this.hidden && this.noAutoRender) return [];
 
+    // Get rendered stops.
+    const stops: Array<HTMLElement | AbortStop> =
+      this.diffBuilder.getLineNumberRows();
+
+    // If we are still loading this diff, abort after the rendered stops to
+    // avoid skipping over to e.g. the next file.
     if (this.loading) {
-      return [new AbortStop()];
+      stops.push(new AbortStop());
     }
-    return this.$.diffBuilder.getLineNumberRows();
+    return stops;
   }
 
   isRangeSelected() {
-    return !!this.$.highlights.selectedRange;
+    return !!this.highlights.selectedRange;
   }
 
   toggleLeftDiff() {
@@ -501,7 +517,7 @@
 
   _blameChanged(newValue?: BlameInfo[] | null) {
     if (newValue === undefined) return;
-    this.$.diffBuilder.setBlame(newValue);
+    this.diffBuilder.setBlame(newValue);
     if (newValue) {
       this.classList.add('showBlame');
     } else {
@@ -523,7 +539,7 @@
     return classes.join(' ');
   }
 
-  _handleTap(e: CustomEvent) {
+  _handleTap(e: Event) {
     const el = (dom(e) as EventApi).localTarget as Element;
 
     if (
@@ -584,7 +600,7 @@
     if (!this.isRangeSelected()) {
       throw Error('Selection is needed for new range comment');
     }
-    const selectedRange = this.$.highlights.selectedRange;
+    const selectedRange = this.highlights.selectedRange;
     if (!selectedRange) throw Error('selected range not set');
     const {side, range} = selectedRange;
     this._createCommentForSelection(side, range);
@@ -592,7 +608,7 @@
 
   _createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
-    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
     if (lineEl) {
       this._createComment(lineEl, lineNum, side, range);
     }
@@ -610,7 +626,7 @@
     side?: Side,
     range?: CommentRange
   ) {
-    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
@@ -652,28 +668,11 @@
   }
 
   _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
-    if (!this._prefsEqual(newPrefs, oldPrefs)) {
+    if (!deepEqual(newPrefs, oldPrefs)) {
       this._prefsChanged(newPrefs);
     }
   }
 
-  _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
-    if (prefs1 === prefs2) {
-      return true;
-    }
-    if (!prefs1 || !prefs2) {
-      return false;
-    }
-    // Scan the preference objects one level deep to see if they differ.
-    const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
-    const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
-    return (
-      keys1.length === keys2.length &&
-      keys1.every(key => prefs1[key] === prefs2[key]) &&
-      keys2.every(key => prefs1[key] === prefs2[key])
-    );
-  }
-
   _pathObserver() {
     // Call _prefsChanged(), because line-limit style value depends on path.
     this._prefsChanged(this.prefs);
@@ -688,7 +687,7 @@
     if (!this.lineOfInterest) return;
     const lineNum = this.lineOfInterest.lineNum;
     if (typeof lineNum !== 'number') return;
-    this.$.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
   _cleanup() {
@@ -752,6 +751,10 @@
       // border-right in ".section" css definition (in gr-diff_html.ts)
       const sectionRightBorder = '1px';
 
+      // each sign col has 1ch width.
+      const signColsWidth =
+        sideBySide && renderPrefs?.show_sign_col ? '2ch' : '0ch';
+
       // As some of these calculations are done using 'ch' we end up
       // having <1px difference between ideal and calculated size for each side
       // leading to lines using the max columns (e.g. 80) to wrap (decided
@@ -765,7 +768,7 @@
       const dontWrapCorrection = '2px';
       stylesToUpdate[
         '--diff-max-width'
-      ] = `calc(${contentWidth} + ${lineNumberWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
+      ] = `calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
     } else {
       stylesToUpdate['--diff-max-width'] = 'none';
     }
@@ -787,10 +790,13 @@
     if (renderPrefs.hide_line_length_indicator) {
       this.classList.add('hide-line-length-indicator');
     }
+    if (renderPrefs.show_sign_col) {
+      this.classList.add('with-sign-col');
+    }
     if (this.prefs) {
       this._updatePreferenceStyles(this.prefs, renderPrefs);
     }
-    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
+    this.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
   _diffChanged(newValue?: DiffInfo) {
@@ -800,6 +806,10 @@
       this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
     }
+    if (this.diff) {
+      this.diffSelection.init(this.diff, this.$.diffTable);
+      this.highlights.init(this.$.diffTable, this.diffBuilder);
+    }
   }
 
   /**
@@ -844,17 +854,24 @@
     this._showWarning = false;
 
     const keyLocations = this._computeKeyLocations();
-    this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder.renderPrefs = this.renderPrefs;
-    this.$.diffBuilder.render(keyLocations).then(() => {
-      this.dispatchEvent(
-        new CustomEvent('render', {
-          bubbles: true,
-          composed: true,
-          detail: {contentRendered: true},
-        })
-      );
-    });
+
+    // TODO: Setting tons of public properties like this is obviously a code
+    // smell. We are planning to introduce a diff model for managing all this
+    // data. Then diff builder will only need access to that model.
+    this.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
+    this.diffBuilder.renderPrefs = this.renderPrefs;
+    this.diffBuilder.diff = this.diff;
+    this.diffBuilder.path = this.path;
+    this.diffBuilder.viewMode = this.viewMode;
+    this.diffBuilder.layers = this.layers ?? [];
+    this.diffBuilder.isImageDiff = this.isImageDiff;
+    this.diffBuilder.baseImage = this.baseImage ?? null;
+    this.diffBuilder.revisionImage = this.revisionImage ?? null;
+    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+    this.diffBuilder.diffElement = this.$.diffTable;
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    this.diffBuilder.render(keyLocations);
   }
 
   _handleRenderContent() {
@@ -863,6 +880,9 @@
     );
     this._setLoading(false);
     this._unobserveIncrementalNodes();
+    // We are just converting 'render-content' into 'render' here. Maybe we
+    // should retire the 'render' event in favor of 'render-content'?
+    fireEvent(this, 'render');
     this._incrementalNodeObserver = (
       dom(this) as PolymerDomWrapper
     ).observeNodes(info => {
@@ -878,10 +898,7 @@
         const commentSide = getSide(threadEl);
         const range = getRange(threadEl);
         if (!commentSide) continue;
-        const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNum,
-          commentSide
-        );
+        const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
         // When the line the comment refers to does not exist, log an error
         // but don't crash. This can happen e.g. if the API does not fully
         // validate e.g. (robot) comments
@@ -894,7 +911,7 @@
           );
           continue;
         }
-        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+        const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
         if (!contentEl) continue;
         if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
           contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
@@ -922,7 +939,7 @@
         // The thread group may already have a slot with the right name, but
         // that is okay because the first matching slot is used and the rest
         // are ignored.
-        const slot = document.createElement('slot') as HTMLSlotElement;
+        const slot = document.createElement('slot');
         if (slotAtt) slot.name = slotAtt;
         threadGroupEl.appendChild(slot);
         lastEl = threadEl;
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 82fa564..40d4e7f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -18,10 +18,13 @@
 
 export const htmlTemplate = html`
   <style include="shared-styles">
-    :host(.no-left) .sideBySide .left,
-    :host(.no-left) .sideBySide .left + td,
-    :host(.no-left) .sideBySide .right:not([data-value]),
-    :host(.no-left) .sideBySide .right:not([data-value]) + td {
+    /**
+     * This is used to hide all left side of the diff (e.g. diffs besides comments
+     * in the change log). Since we want to remove the first 4 cells consistently
+     * in all rows except context buttons (.dividerRow).
+     */
+    :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+    :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
       display: none;
     }
     :host(.disable-context-control-buttons) {
@@ -67,8 +70,8 @@
     }
 
     /* Provides the option to add side borders (left and right) to the line number column. */
-    td.left,
-    td.right,
+    td.lineNum,
+    td.blankLineNum,
     td.moveControlsLineNumCol,
     td.contextLineNum {
       box-shadow: var(--line-number-box-shadow, unset);
@@ -160,9 +163,74 @@
     .diff-row.target-row.target-side-left .lineNumButton.left,
     .diff-row.target-row.target-side-right .lineNumButton.right,
     .diff-row.target-row.unified .lineNumButton {
-      background-color: var(--diff-selection-background-color);
       color: var(--primary-text-color);
     }
+
+    /**
+     * Preparing selected line cells with position relative so it allows a positioned overlay with 'position: absolute'.
+     */
+    .target-row td {
+      position: relative;
+    }
+
+    /**
+     * Defines an overlay to the selected line for drawing an outline without blocking user interaction (e.g. text selection).
+     */
+    .target-row td::before {
+      border-width: 0;
+      border-style: solid;
+      border-color: var(--focused-line-outline-color);
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      pointer-events: none;
+      user-select: none;
+      content: ' ';
+    }
+
+    /**
+     * the outline for the selected content cell should be the same in all cases.
+     */
+    .target-row.target-side-left td.left.content::before,
+    .target-row.target-side-right td.right.content::before,
+    .unified.target-row td.content::before {
+      border-width: 1px 1px 1px 0;
+    }
+
+    /**
+     * the outline for the sign cell should be always be contiguous top/bottom.
+     */
+    .target-row.target-side-left td.left.sign::before,
+    .target-row.target-side-right td.right.sign::before {
+      border-width: 1px 0;
+    }
+
+    /**
+     * For side-by-side we need to select the correct line number to "visually close"
+     * the outline.
+     */
+    .side-by-side.target-row.target-side-left td.left.lineNum::before,
+    .side-by-side.target-row.target-side-right td.right.lineNum::before {
+      border-width: 1px 0 1px 1px;
+    }
+
+    /**
+     * For unified diff we always start the overlay from the left cell
+     */
+    .unified.target-row td.left:not(.content)::before {
+      border-width: 1px 0 1px 1px;
+    }
+
+    /**
+     * For unified diff we should continue the top/bottom border in right
+     * line number column.
+     */
+    .unified.target-row td.right:not(.content)::before {
+      border-width: 1px 0;
+    }
+
     .content {
       background-color: var(--diff-blank-background-color);
     }
@@ -226,6 +294,14 @@
     .canComment .lineNumButton {
       cursor: pointer;
     }
+    .sign {
+      min-width: 1ch;
+      width: 1ch;
+      background-color: var(--view-background-color);
+    }
+    .sign.blank {
+      background-color: var(--diff-blank-background-color);
+    }
     .content {
       /* Set min width since setting width on table cells still
            allows them to shrink. Do not set max width because
@@ -236,22 +312,30 @@
     .content.add .contentText .intraline,
       /* If there are no intraline info, consider everything changed */
       .content.add.no-intraline-info .contentText,
+      .sign.add.no-intraline-info,
       .delta.total .content.add .contentText {
       background-color: var(--dark-add-highlight-color);
     }
-    .content.add .contentText {
+    .content.add .contentText,
+    .sign.add {
       background-color: var(--light-add-highlight-color);
     }
     .content.remove .contentText .intraline,
       /* If there are no intraline info, consider everything changed */
       .content.remove.no-intraline-info .contentText,
-      .delta.total .content.remove .contentText {
+      .delta.total .content.remove .contentText,
+      .sign.remove.no-intraline-info {
       background-color: var(--dark-remove-highlight-color);
     }
-    .content.remove .contentText {
+    .content.remove .contentText,
+    .sign.remove {
       background-color: var(--light-remove-highlight-color);
     }
 
+    .ignoredWhitespaceOnly .sign.no-intraline-info {
+      background-color: var(--view-background-color);
+    }
+
     /* dueToRebase */
     .dueToRebase .content.add .contentText .intraline,
     .delta.total.dueToRebase .content.add .contentText {
@@ -269,14 +353,18 @@
     }
 
     /* dueToMove */
+    .dueToMove .sign.add,
     .dueToMove .content.add .contentText,
+    .dueToMove .moveControls.movedIn .sign.right,
     .dueToMove .moveControls.movedIn .moveHeader,
     .delta.total.dueToMove .content.add .contentText {
       background-color: var(--diff-moved-in-background);
     }
 
+    .dueToMove .sign.remove,
     .dueToMove .content.remove .contentText,
     .dueToMove .moveControls.movedOut .moveHeader,
+    .dueToMove .moveControls.movedOut .sign.left,
     .delta.total.dueToMove .content.remove .contentText {
       background-color: var(--diff-moved-out-background);
     }
@@ -353,9 +441,6 @@
       height: 0;
     }
 
-    .displayLine .diff-row.target-row td {
-      box-shadow: inset 0 -1px var(--border-color);
-    }
     .br:after {
       /* Line feed */
       content: '\\A';
@@ -402,6 +487,10 @@
       color: var(--link-color);
       padding: var(--spacing-m) 0 var(--spacing-m) 48px;
     }
+    #diffTable {
+      /* for gr-selection-action-box positioning */
+      position: relative;
+    }
     #diffTable:focus {
       outline: none;
     }
@@ -438,6 +527,21 @@
       padding: 0 var(--spacing-s) 0 var(--spacing-m);
       color: var(--blue-700);
     }
+
+    col.sign,
+    td.sign {
+      display: none;
+    }
+
+    /**
+     * Sign column should only be shown in high-contrast mode.
+     */
+    :host(.with-sign-col) col.sign {
+      display: table-column;
+    }
+    :host(.with-sign-col) td.sign {
+      display: table-cell;
+    }
     col.blame {
       display: none;
     }
@@ -549,11 +653,11 @@
       user-select: text;
     }
 
-    /** Make comments selectable when selected */
+    /** Make comments and check results selectable when selected */
     .selected-left.selected-comment
-      ::slotted(gr-comment-thread[diff-side='left']),
+      ::slotted(.comment-thread[diff-side='left']),
     .selected-right.selected-comment
-      ::slotted(gr-comment-thread[diff-side='right']) {
+      ::slotted(.comment-thread[diff-side='right']) {
       -webkit-user-select: text;
       -moz-user-select: text;
       -ms-user-select: text;
@@ -570,6 +674,14 @@
     .token-highlight {
       background-color: var(--token-highlighting-color, #fffd54);
     }
+
+    gr-selection-action-box {
+      /**
+       * Needs z-index to appear above wrapped content, since it's inserted
+       * into DOM before it.
+       */
+      z-index: 10;
+    }
   </style>
   <style include="gr-syntax-theme">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -586,44 +698,22 @@
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
   >
-    <gr-diff-selection diff="[[diff]]">
-      <gr-diff-highlight
-        id="highlights"
-        logged-in="[[loggedIn]]"
-        comment-ranges="{{_commentRanges}}"
-      >
-        <gr-diff-builder
-          id="diffBuilder"
-          comment-ranges="[[_commentRanges]]"
-          coverage-ranges="[[coverageRanges]]"
-          diff="[[diff]]"
-          path="[[path]]"
-          view-mode="[[viewMode]]"
-          is-image-diff="[[isImageDiff]]"
-          base-image="[[baseImage]]"
-          layers="[[layers]]"
-          revision-image="[[revisionImage]]"
-          use-new-image-diff-ui="[[useNewImageDiffUi]]"
-        >
-          <table
-            id="diffTable"
-            class$="[[_diffTableClass]]"
-            role="presentation"
-            contenteditable$="[[isContentEditable]]"
-          ></table>
+    <table
+      id="diffTable"
+      class$="[[_diffTableClass]]"
+      role="presentation"
+      contenteditable$="[[isContentEditable]]"
+    ></table>
 
-          <template
-            is="dom-if"
-            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-          >
-            <div class="whitespace-change-only-message">
-              This file only contains whitespace changes. Modify the whitespace
-              setting to see the changes.
-            </div>
-          </template>
-        </gr-diff-builder>
-      </gr-diff-highlight>
-    </gr-diff-selection>
+    <template
+      is="dom-if"
+      if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+    >
+      <div class="whitespace-change-only-message">
+        This file only contains whitespace changes. Modify the whitespace
+        setting to see the changes.
+      </div>
+    </template>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
deleted file mode 100644
index 80c7f11..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ /dev/null
@@ -1,1231 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-diff.js';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
-import {runA11yAudit} from '../../../test/a11y-test-utils.js';
-import '@polymer/paper-button/paper-button.js';
-import {Side} from '../../../api/diff.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-diff');
-
-suite('gr-diff a11y test', () => {
-  test('audit', async () => {
-    await runA11yAudit(basicFixture);
-  });
-});
-
-suite('gr-diff tests', () => {
-  let element;
-
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
-
-  setup(() => {
-
-  });
-
-  suite('selectionchange event handling', () => {
-    const emulateSelection = function() {
-      document.dispatchEvent(new CustomEvent('selectionchange'));
-    };
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element.$.highlights, 'handleSelectionChange');
-    });
-
-    test('enabled if logged in', async () => {
-      element.loggedIn = true;
-      emulateSelection();
-      await flush();
-      assert.isTrue(element.$.highlights.handleSelectionChange.called);
-    });
-
-    test('ignored if logged out', async () => {
-      element.loggedIn = false;
-      emulateSelection();
-      await flush();
-      assert.isFalse(element.$.highlights.handleSelectionChange.called);
-    });
-  });
-
-  test('cancel', () => {
-    element = basicFixture.instantiate();
-    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
-    element.cancel();
-    assert.isTrue(cancelStub.calledOnce);
-  });
-
-  test('line limit with line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
-  });
-
-  test('line limit without line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
-  });
-  suite('FULL_RESPONSIVE mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
-    });
-
-    test('line limit is based on line_length', () => {
-      element.prefs = {...element.prefs, line_length: 100};
-      flush();
-      assert.equal(getComputedStyleValue('--line-limit-marker', element),
-          '100ch');
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-  });
-
-  suite('SHRINK_ONLY mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-
-    test('max-width considers two content columns in side-by-side', () => {
-      element.viewMode = 'SIDE_BY_SIDE';
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 1px + 2px)');
-    });
-
-    test('max-width considers one content column in unified', () => {
-      element.viewMode = 'UNIFIED_DIFF';
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(1 * 80ch + 2 * 48px + 1px + 2px)');
-    });
-
-    test('max-width considers font-size', () => {
-      element.prefs = {...element.prefs, font_size: 13};
-      flush();
-      // Each line number column: 4 * 13 = 52px
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 1px + 2px)');
-    });
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      const getLoggedInPromise = Promise.resolve(false);
-      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
-      element = basicFixture.instantiate();
-      return getLoggedInPromise;
-    });
-
-    test('toggleLeftDiff', () => {
-      element.toggleLeftDiff();
-      assert.isTrue(element.classList.contains('no-left'));
-      element.toggleLeftDiff();
-      assert.isFalse(element.classList.contains('no-left'));
-    });
-
-    test('view does not start with displayLine classList', () => {
-      assert.isFalse(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('displayLine class added called when displayLine is true', () => {
-      const spy = sinon.spy(element, '_computeContainerClass');
-      element.displayLine = true;
-      assert.isTrue(spy.called);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('thread groups', () => {
-      const contentEl = document.createElement('div');
-
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element.$.diffBuilder.diff = getMockDiffResponse();
-      element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
-
-      // No thread groups.
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
-
-      // A thread group gets created.
-      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
-      assert.isOk(threadGroupEl);
-
-      // The new thread group can be fetched.
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
-    });
-
-    suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
-      setup(() => {
-        mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
-          type: 'image/bmp',
-        };
-
-        element.isImageDiff = true;
-        element.prefs = {
-          context: 10,
-          cursor_blink_rate: 0,
-          font_size: 12,
-          ignore_whitespace: 'IGNORE_NONE',
-          line_length: 100,
-          line_wrapping: false,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        };
-      });
-
-      test('renders image diffs with same file name', async () => {
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.revisionImage = mockFile2;
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders image diffs with a different file name', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot2.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot2.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isOk(rightLabelName);
-          assert.isOk(leftLabelName);
-          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.baseImage._name = mockDiff.meta_a.name;
-        element.revisionImage = mockFile2;
-        element.revisionImage._name = mockDiff.meta_b.name;
-        element.diff = mockDiff;
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders added image', async () => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'ADDED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 0000000..f9c2f2c 100644',
-            '--- /dev/null',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.revisionImage = mockFile2;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
-      });
-
-      test('renders removed image', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
-        assert.isNotOk(rightImage);
-      });
-
-      test('does not render disallowed image type', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        mockFile1.type = 'image/jpeg-evil';
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        assert.isNotOk(leftImage);
-      });
-    });
-
-    test('_handleTap lineNum', async () => {
-      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
-      const el = document.createElement('div');
-      el.className = 'lineNum';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(addDraftStub.called);
-        assert.equal(addDraftStub.lastCall.args[0], el);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
-    test('_handleTap content', async () => {
-      const content = document.createElement('div');
-      const lineEl = document.createElement('div');
-      lineEl.className = 'lineNum';
-      const row = document.createElement('div');
-      row.appendChild(lineEl);
-      row.appendChild(content);
-
-      const selectStub = sinon.stub(element, '_selectLine');
-
-      content.className = 'content';
-      const promise = mockPromise();
-      content.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(selectStub.called);
-        assert.equal(selectStub.lastCall.args[0], lineEl);
-        promise.resolve();
-      });
-      content.click();
-      await promise;
-    });
-
-    suite('getCursorStops', () => {
-      function setupDiff() {
-        element.diff = getMockDiffResponse();
-        element.prefs = {
-          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,
-          theme: 'DEFAULT',
-          ignore_whitespace: 'IGNORE_NONE',
-        };
-
-        element._renderDiffTable();
-        element._setLoading(false);
-        flush();
-      }
-
-      test('getCursorStops returns [] when hidden and noAutoRender', () => {
-        element.noAutoRender = true;
-        setupDiff();
-        element.hidden = true;
-        assert.equal(element.getCursorStops().length, 0);
-      });
-
-      test('getCursorStops', () => {
-        setupDiff();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
-      });
-    });
-
-    test('adds .hiddenscroll', () => {
-      _setHiddenScroll(true);
-      element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.diffContainer').className, 'hiddenscroll');
-    });
-  });
-
-  suite('logged in', () => {
-    let fakeLineEl;
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.loggedIn = true;
-
-      fakeLineEl = {
-        getAttribute: sinon.stub().returns(42),
-        classList: {
-          contains: sinon.stub().returns(true),
-        },
-      };
-    });
-
-    test('addDraftAtLine', () => {
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(element._createComment
-          .calledWithExactly(fakeLineEl, 42));
-    });
-
-    test('adds long range comment hint', async () => {
-      const range = {
-        start_line: 1,
-        end_line: 12,
-        start_character: 0,
-        end_character: 0,
-      };
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
-      threadEl.setAttribute('range', JSON.stringify(range));
-      threadEl.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
-      setupSampleDiff({content});
-
-      element.appendChild(threadEl);
-      await flush();
-
-      assert.deepEqual(
-          element.querySelector('gr-ranged-comment-hint').range, range);
-    });
-
-    test('no duplicate range hint for same thread', async () => {
-      const range = {
-        start_line: 1,
-        end_line: 12,
-        start_character: 0,
-        end_character: 0,
-      };
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
-      threadEl.setAttribute('range', JSON.stringify(range));
-      threadEl.setAttribute('slot', 'right-1');
-      const firstHint = document.createElement('gr-ranged-comment-hint');
-      firstHint.range = range;
-      firstHint.setAttribute('threadElRootId', threadEl.rootId);
-      firstHint.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
-      setupSampleDiff({content});
-
-      element.appendChild(firstHint);
-      await flush();
-      element._handleRenderContent();
-      await flush();
-      element.appendChild(threadEl);
-      await flush();
-
-      assert.equal(
-          element.querySelectorAll('gr-ranged-comment-hint').length, 1);
-    });
-
-    test('removes long range comment hint when comment is discarded',
-        async () => {
-          const range = {
-            start_line: 1,
-            end_line: 7,
-            start_character: 0,
-            end_character: 0,
-          };
-          const threadEl = document.createElement('div');
-          threadEl.className = 'comment-thread';
-          threadEl.setAttribute('diff-side', 'right');
-          threadEl.setAttribute('line-num', 1);
-          threadEl.setAttribute('range', JSON.stringify(range));
-          threadEl.setAttribute('slot', 'right-1');
-          const content = [{
-            a: [],
-            b: [],
-          }, {
-            ab: Array(8).fill('text'),
-          }];
-          setupSampleDiff({content});
-          element.appendChild(threadEl);
-          await flush();
-
-          threadEl.remove();
-          await flush();
-
-          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
-        });
-
-    suite('change in preferences', () => {
-      setup(() => {
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-        element.renderDiffTableTask.flush();
-      });
-
-      test('change in preferences re-renders diff', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('adding/removing property in preferences re-renders diff', () => {
-        const stub = sinon.stub(element, '_renderDiffTable');
-        const newPrefs1 = {...MINIMAL_PREFS,
-          line_wrapping: true};
-        element.prefs = newPrefs1;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-        stub.reset();
-
-        const newPrefs2 = {...newPrefs1};
-        delete newPrefs2.line_wrapping;
-        element.prefs = newPrefs2;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.noRenderOnPrefsChange = true;
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isFalse(element._renderDiffTable.called);
-      });
-    });
-  });
-
-  suite('diff header', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.diff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        diff_header: [],
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        content: [{skip: 66}],
-      };
-    });
-
-    test('hidden', () => {
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '--- a/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '+++ b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      flush();
-
-      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-    });
-
-    test('binary files', () => {
-      element.diff.binary = true;
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      element.push('diff.diff_header', 'Binary files differ');
-      assert.equal(element._diffHeaderItems.length, 1);
-    });
-  });
-
-  suite('safety and bypass', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
-          () => {
-            element.$.diffBuilder.dispatchEvent(
-                new CustomEvent('render', {bubbles: true, composed: true}));
-            return Promise.resolve({});
-          });
-      sinon.stub(element, 'getDiffLength').returns(10000);
-      element.diff = getMockDiffResponse();
-      element.noRenderOnPrefsChange = true;
-    });
-
-    test('large render w/ context = 10', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 10};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      element._safetyBypass = 10;
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and no bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isFalse(renderStub.called);
-        assert.isTrue(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('toggles expand context using bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, 3);
-      assert.equal(element._safetyBypass, -1);
-      assert.equal(element.$.diffBuilder.prefs.context, -1);
-    });
-
-    test('toggles collapse context from bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-      element._safetyBypass = -1;
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, 3);
-      assert.isNull(element._safetyBypass);
-      assert.equal(element.$.diffBuilder.prefs.context, 3);
-    });
-
-    test('toggles collapse context from pref using default', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, -1);
-      assert.equal(element._safetyBypass, 10);
-      assert.equal(element.$.diffBuilder.prefs.context, 10);
-    });
-  });
-
-  suite('blame', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    test('unsetting', () => {
-      element.blame = [];
-      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
-      element.classList.add('showBlame');
-      element.blame = null;
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.isFalse(element.classList.contains('showBlame'));
-    });
-
-    test('setting', () => {
-      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      assert.isTrue(element.classList.contains('showBlame'));
-    });
-  });
-
-  suite('trailing newline warnings', () => {
-    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
-    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
-
-    const getWarning = element =>
-      element.shadowRoot.querySelector('.newlineWarning').textContent;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.showNewlineWarningLeft = false;
-      element.showNewlineWarningRight = false;
-    });
-
-    test('shows combined warning if both sides set to warn', () => {
-      element.showNewlineWarningLeft = true;
-      element.showNewlineWarningRight = true;
-      assert.include(
-          getWarning(element),
-          NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT);// \u2014 - '—'
-    });
-
-    suite('showNewlineWarningLeft', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningLeft = true;
-        assert.include(getWarning(element), NO_NEWLINE_LEFT);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningLeft = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
-    });
-
-    suite('showNewlineWarningRight', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningRight = true;
-        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningRight = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-    });
-
-    test('_computeNewlineWarningClass', () => {
-      const hidden = 'newlineWarning hidden';
-      const shown = 'newlineWarning';
-      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-    });
-
-    test('_prefsEqual', () => {
-      element = basicFixture.instantiate();
-      assert.isTrue(element._prefsEqual(null, null));
-      assert.isTrue(element._prefsEqual({}, {}));
-      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-      assert.isTrue(
-          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-      const somePref = {abc: 'def', p: true};
-      assert.isTrue(element._prefsEqual(somePref, somePref));
-
-      assert.isFalse(element._prefsEqual({}, null));
-      assert.isFalse(element._prefsEqual(null, {}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-    });
-  });
-
-  suite('key locations', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {};
-      renderStub = sinon.stub(element.$.diffBuilder, 'render')
-          .returns(new Promise(() => {}));
-    });
-
-    test('lineOfInterest is a key location', () => {
-      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {789: true},
-        right: {},
-      });
-    });
-
-    test('line comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {},
-        right: {3: true},
-      });
-    });
-
-    test('file comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'left');
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {FILE: true},
-        right: {},
-      });
-    });
-  });
-  const setupSampleDiff = function(params) {
-    const {ignore_whitespace, content} = params;
-    // binary can't be undefined, use false if not set
-    const binary = params.binary || false;
-    element = basicFixture.instantiate();
-    element.prefs = {
-      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
-      context: 10,
-      cursor_blink_rate: 0,
-      font_size: 12,
-
-      line_length: 100,
-      line_wrapping: false,
-      show_line_endings: true,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
-    element.diff = {
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/carrot.js b/carrot.js',
-        'index 2adc47d..f9c2f2c 100644',
-        '--- a/carrot.js',
-        '+++ b/carrot.jjs',
-        'file differ',
-      ],
-      content,
-      binary,
-    };
-    element._renderDiffTable();
-    flush();
-  };
-
-  test('clear diff table content as soon as diff changes', () => {
-    const content = [{
-      a: ['all work and no play make andybons a dull boy'],
-    }, {
-      b: [
-        'Non eram nescius, Brute, cum, quae summis ingeniis ',
-      ],
-    }];
-    function assertDiffTableWithContent() {
-      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
-    }
-    setupSampleDiff({content});
-    assertDiffTableWithContent();
-    element.diff = {...element.diff};
-    // immediately cleaned up
-    assert.equal(element.$.diffTable.innerHTML, '');
-    element._renderDiffTable();
-    flush();
-    // rendered again
-    assertDiffTableWithContent();
-  });
-
-  suite('selection test', () => {
-    test('user-select set correctly on side-by-side view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      // click to mark it as selected
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-
-    test('user-select set correctly on unified view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      element.viewMode = 'UNIFIED_DIFF';
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-  });
-
-  suite('whitespace changes only message', () => {
-    test('show the message if ignore_whitespace is criteria matches', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isTrue(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message for binary files', () => {
-      setupSampleDiff({content: [{skip: 100}], binary: true});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if still loading', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ true,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if contains valid changes', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      assert.equal(element._diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show message if ignore whitespace is disabled', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-  });
-
-  test('getDiffLength', () => {
-    const diff = getMockDiffResponse();
-    assert.equal(element.getDiffLength(diff), 52);
-  });
-
-  test('`render` event has contentRendered field in detail', async () => {
-    element = basicFixture.instantiate();
-    element.prefs = {};
-    sinon.stub(element.$.diffBuilder, 'render')
-        .returns(Promise.resolve());
-    const promise = mockPromise();
-    element.addEventListener('render', event => {
-      assert.isTrue(event.detail.contentRendered);
-      promise.resolve();
-    });
-    element._renderDiffTable();
-    await promise;
-  });
-
-  test('_prefsEqual', () => {
-    element = basicFixture.instantiate();
-    assert.isTrue(element._prefsEqual(null, null));
-    assert.isTrue(element._prefsEqual({}, {}));
-    assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-    assert.isTrue(element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-    const somePref = {abc: 'def', p: true};
-    assert.isTrue(element._prefsEqual(somePref, somePref));
-
-    assert.isFalse(element._prefsEqual({}, null));
-    assert.isFalse(element._prefsEqual(null, {}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
new file mode 100644
index 0000000..183cdfb
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -0,0 +1,1244 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll';
+import {runA11yAudit} from '../../../test/a11y-test-utils';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../api/diff';
+import {
+  mockPromise,
+  mouseDown,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
+
+const basicFixture = fixtureFromElement('gr-diff');
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    await runA11yAudit(basicFixture);
+  });
+});
+
+suite('gr-diff tests', () => {
+  let element: GrDiff;
+
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
+
+  setup(() => {});
+
+  suite('selectionchange event handling', () => {
+    let handleSelectionChangeStub: sinon.SinonSpy;
+
+    const emulateSelection = function () {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      handleSelectionChangeStub = sinon.spy(
+        element.highlights,
+        'handleSelectionChange'
+      );
+    });
+
+    test('enabled if logged in', async () => {
+      element.loggedIn = true;
+      emulateSelection();
+      await flush();
+      assert.isTrue(handleSelectionChangeStub.called);
+    });
+
+    test('ignored if logged out', async () => {
+      element.loggedIn = false;
+      emulateSelection();
+      await flush();
+      assert.isFalse(handleSelectionChangeStub.called);
+    });
+  });
+
+  test('cancel', () => {
+    element = basicFixture.instantiate();
+    const cancelStub = sinon.stub(element.diffBuilder, 'cancel');
+    element.cancel();
+    assert.isTrue(cancelStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+    flush();
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+    flush();
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
+  });
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+    });
+
+    test('line limit is based on line_length', () => {
+      element.prefs = {...element.prefs!, line_length: 100};
+      flush();
+      assert.equal(
+        getComputedStyleValue('--line-limit-marker', element),
+        '100ch'
+      );
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', () => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      flush();
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers one content column in unified', () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      flush();
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers font-size', () => {
+      element.prefs = {...element.prefs!, font_size: 13};
+      flush();
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('sign cols are considered if show_sign_col is true', () => {
+      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
+      flush();
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+      );
+    });
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      const getLoggedInPromise = Promise.resolve(false);
+      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
+      element = basicFixture.instantiate();
+      return getLoggedInPromise;
+    });
+
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    test('view does not start with displayLine classList', () => {
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isFalse(container.classList.contains('displayLine'));
+    });
+
+    test('displayLine class added called when displayLine is true', () => {
+      const spy = sinon.spy(element, '_computeContainerClass');
+      element.displayLine = true;
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isTrue(spy.called);
+      assert.isTrue(container.classList.contains('displayLine'));
+    });
+
+    test('thread groups', () => {
+      const contentEl = document.createElement('div');
+
+      element.path = 'file.txt';
+
+      // No thread groups.
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
+
+      // A thread group gets created.
+      const threadGroupEl = element._getOrCreateThreadGroup(
+        contentEl,
+        Side.LEFT
+      );
+      assert.isOk(threadGroupEl);
+
+      // The new thread group can be fetched.
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+    });
+
+    suite('image diffs', () => {
+      let mockFile1: ImageInfo;
+      let mockFile2: ImageInfo;
+      setup(() => {
+        mockFile1 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+
+        element.isImageDiff = true;
+        element.prefs = {
+          context: 10,
+          cursor_blink_rate: 0,
+          font_size: 12,
+          ignore_whitespace: 'IGNORE_NONE',
+          line_length: 100,
+          line_wrapping: false,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+        };
+      });
+
+      test('renders image diffs with same file name', async () => {
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        await waitForEventOnce(element, 'render');
+
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = query(leftLabel, '.name');
+
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = query(rightLabel, '.name');
+
+        assert.isNotOk(rightLabelName);
+        assert.isNotOk(leftLabelName);
+
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
+
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
+      });
+
+      test('renders image diffs with a different file name', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a!.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b!.name;
+        element.diff = mockDiff;
+        await waitForEventOnce(element, 'render');
+
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = queryAndAssert(leftLabel, '.name');
+
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = queryAndAssert(rightLabel, '.name');
+
+        assert.isOk(rightLabelName);
+        assert.isOk(leftLabelName);
+        assert.equal(leftLabelName.textContent, mockDiff.meta_a?.name);
+        assert.equal(rightLabelName.textContent, mockDiff.meta_b?.name);
+
+        assert.isOk(leftImage);
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
+
+        assert.isOk(rightImage);
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
+      });
+
+      test('renders added image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+        queryAndAssert(diffTable, 'td.right img');
+      });
+
+      test('renders removed image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        const diffTable = element.$.diffTable;
+        queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
+        assert.isNotOk(rightImage);
+      });
+
+      test('does not render disallowed image type', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {
+            name: 'carrot.jpg',
+            content_type: 'image/jpeg-evil',
+            lines: 560,
+          },
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+
+    test('_handleTap lineNum', async () => {
+      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      const promise = mockPromise();
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        promise.resolve();
+      });
+      el.click();
+      await promise;
+    });
+
+    test('_handleTap content', async () => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+      lineEl.className = 'lineNum';
+      const row = document.createElement('div');
+      row.appendChild(lineEl);
+      row.appendChild(content);
+
+      const selectStub = sinon.stub(element, '_selectLine');
+
+      content.className = 'content';
+      const promise = mockPromise();
+      content.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        promise.resolve();
+      });
+      content.click();
+      await promise;
+    });
+
+    suite('getCursorStops', () => {
+      function setupDiff() {
+        element.diff = createDiff();
+        element.prefs = {
+          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._renderDiffTable();
+
+        flush();
+      }
+
+      test('returns [] when hidden and noAutoRender', () => {
+        element.noAutoRender = true;
+        setupDiff();
+        element._setLoading(false);
+        flush();
+        element.hidden = true;
+        assert.equal(element.getCursorStops().length, 0);
+      });
+
+      test('returns one stop per line and one for the file row', () => {
+        setupDiff();
+        element._setLoading(false);
+        flush();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+      });
+
+      test('returns an additional AbortStop when still loading', () => {
+        setupDiff();
+        element._setLoading(true);
+        flush();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        const actual = element.getCursorStops();
+        assert.equal(actual.length, ROWS + FILE_ROW + 1);
+        assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
+      });
+    });
+
+    test('adds .hiddenscroll', () => {
+      _setHiddenScroll(true);
+      element.displayLine = true;
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.include(container.className, 'hiddenscroll');
+    });
+  });
+
+  suite('logged in', () => {
+    let fakeLineEl: HTMLElement;
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.loggedIn = true;
+
+      fakeLineEl = {
+        getAttribute: sinon.stub().returns(42),
+        classList: {
+          contains: sinon.stub().returns(true),
+        },
+      } as unknown as HTMLElement;
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, '_selectLine');
+      const createCommentStub = sinon.stub(element, '_createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('adds long range comment hint', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
+      await waitForEventOnce(element, 'render');
+
+      element.appendChild(threadEl);
+      await flush();
+
+      const hint = queryAndAssert<GrRangedCommentHint>(
+        element,
+        'gr-ranged-comment-hint'
+      );
+      assert.deepEqual(hint.range, range);
+    });
+
+    test('no duplicate range hint for same thread', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const firstHint = document.createElement('gr-ranged-comment-hint');
+      firstHint.range = range;
+      firstHint.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
+
+      element.appendChild(firstHint);
+      await flush();
+      element._handleRenderContent();
+      await flush();
+      element.appendChild(threadEl);
+      await flush();
+
+      assert.equal(
+        element.querySelectorAll('gr-ranged-comment-hint').length,
+        1
+      );
+    });
+
+    test('removes long range comment hint when comment is discarded', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 7,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(8).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
+      element.appendChild(threadEl);
+      await flush();
+
+      threadEl.remove();
+      await flush();
+
+      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+    });
+
+    suite('change in preferences', () => {
+      setup(() => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        element.renderDiffTableTask?.flush();
+      });
+
+      test('change in preferences re-renders diff', () => {
+        const stub = sinon.stub(element, '_renderDiffTable');
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test('adding/removing property in preferences re-renders diff', () => {
+        const stub = sinon.stub(element, '_renderDiffTable');
+        const newPrefs1: DiffPreferencesInfo = {
+          ...MINIMAL_PREFS,
+          line_wrapping: true,
+        };
+        element.prefs = newPrefs1;
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+        stub.reset();
+
+        const newPrefs2 = {...newPrefs1};
+        delete newPrefs2.line_wrapping;
+        element.prefs = newPrefs2;
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test(
+        'change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange',
+        () => {
+          const stub = sinon.stub(element, '_renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {
+            ...MINIMAL_PREFS,
+            context: 12,
+          };
+          element.renderDiffTableTask?.flush();
+          assert.isFalse(stub.called);
+        }
+      );
+    });
+  });
+
+  suite('diff header', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+    });
+
+    test('hidden', () => {
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '--- a/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '+++ b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      flush();
+
+      const header = queryAndAssert(element, '#diffHeader');
+      assert.equal(header.textContent?.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff!.binary = true;
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      element.push('diff.diff_header', 'Binary files differ');
+      assert.equal(element._diffHeaderItems.length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+        const diffTable = element.$.diffTable;
+        diffTable.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true})
+        );
+        return Promise.resolve({});
+      });
+      sinon.stub(element, 'getDiffLength').returns(10000);
+      element.diff = createDiff();
+      element.noRenderOnPrefsChange = true;
+    });
+
+    test('large render w/ context = 10', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 10};
+      const promise = mockPromise();
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+      await promise;
+    });
+
+    test('large render w/ whole file and bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element._safetyBypass = 10;
+      const promise = mockPromise();
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+      await promise;
+    });
+
+    test('large render w/ whole file and no bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      const promise = mockPromise();
+      function rendered() {
+        assert.isFalse(renderStub.called);
+        assert.isTrue(element._showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+      await promise;
+    });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element._safetyBypass, -1);
+      assert.equal(element.diffBuilder.prefs.context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element._safetyBypass = -1;
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element._safetyBypass);
+      assert.equal(element.diffBuilder.prefs.context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element._safetyBypass, 10);
+      assert.equal(element.diffBuilder.prefs.context, 10);
+    });
+  });
+
+  suite('blame', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('unsetting', () => {
+      element.blame = [];
+      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', () => {
+      element.blame = [
+        {
+          author: 'test-author',
+          time: 12345,
+          commit_msg: '',
+          id: 'commit id',
+          ranges: [{start: 1, end: 2}],
+        },
+      ];
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+    const getWarning = (element: GrDiff) => {
+      const warningElement = queryAndAssert(element, '.newlineWarning');
+      return warningElement.textContent;
+    };
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+    });
+
+    test('shows combined warning if both sides set to warn', () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      assert.include(
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningLeft = true;
+        assert.include(getWarning(element), NO_NEWLINE_LEFT);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningLeft = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningRight = true;
+        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningRight = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+    });
+
+    test('_computeNewlineWarningClass', () => {
+      const hidden = 'newlineWarning hidden';
+      const shown = 'newlineWarning';
+      assert.equal(element._computeNewlineWarningClass(false, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(false, false), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, false), shown);
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      renderStub = sinon.stub(element.diffBuilder, 'render');
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '3');
+      element.appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'left');
+      element.appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+  const setupSampleDiff = function (params: {
+    content: DiffContent[];
+    ignore_whitespace?: IgnoreWhitespaceType;
+    binary?: boolean;
+  }) {
+    const {ignore_whitespace, content} = params;
+    // binary can't be undefined, use false if not set
+    const binary = params.binary || false;
+    element = basicFixture.instantiate();
+    element.prefs = {
+      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+      context: 10,
+      cursor_blink_rate: 0,
+      font_size: 12,
+
+      line_length: 100,
+      line_wrapping: false,
+      show_line_endings: true,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+    };
+    element.diff = {
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/carrot.js b/carrot.js',
+        'index 2adc47d..f9c2f2c 100644',
+        '--- a/carrot.js',
+        '+++ b/carrot.jjs',
+        'file differ',
+      ],
+      content,
+      binary,
+    };
+    element._renderDiffTable();
+    flush();
+  };
+
+  test('clear diff table content as soon as diff changes', () => {
+    const content = [
+      {
+        a: ['all work and no play make andybons a dull boy'],
+      },
+      {
+        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+      },
+    ];
+    function assertDiffTableWithContent() {
+      const diffTable = element.$.diffTable;
+      assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
+    }
+    setupSampleDiff({content});
+    assertDiffTableWithContent();
+    element.diff = {...element.diff!};
+    // immediately cleaned up
+    const diffTable = element.$.diffTable;
+    assert.equal(diffTable.innerHTML, '');
+    element._renderDiffTable();
+    flush();
+    // rendered again
+    assertDiffTableWithContent();
+  });
+
+  suite('selection test', () => {
+    test('user-select set correctly on side-by-side view', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({content});
+      flush();
+
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+
+    test('user-select set correctly on unified view', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({content});
+      element.viewMode = DiffViewMode.UNIFIED;
+      flush();
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    test('show the message if ignore_whitespace is criteria matches', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isTrue(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show the message for binary files', () => {
+      setupSampleDiff({content: [{skip: 100}], binary: true});
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show the message if still loading', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ true,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show the message if contains valid changes', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({content});
+      assert.equal(element._diffLength, 3);
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show message if ignore whitespace is disabled', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = createDiff();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 11712dc..70cec64 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -1,30 +1,12 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-ranged-comment-layer_html';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {Side} from '../../../constants/constants';
-import {
-  PolymerDeepPropertyChange,
-  PolymerSpliceChange,
-} from '@polymer/polymer/interfaces';
 import {CommentRange} from '../../../types/common';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
@@ -38,8 +20,18 @@
 export interface CommentRangeLayer {
   side: Side;
   range: CommentRange;
-  hovering: boolean;
-  rootId: string;
+  // New drafts don't have a rootId.
+  rootId?: string;
+}
+
+/** Can be used for array functions like `some()`. */
+function equals(a: CommentRangeLayer) {
+  return (b: CommentRangeLayer) => id(a) === id(b);
+}
+
+function id(r: CommentRangeLayer): string {
+  if (r.rootId) return r.rootId;
+  return `${r.side}-${r.range.start_line}-${r.range.start_character}-${r.range.end_line}-${r.range.end_character}`;
 }
 
 /**
@@ -47,10 +39,11 @@
  * highlights.
  */
 interface CommentRangeLineLayer {
-  hovering: boolean;
   longRange: boolean;
-  rootId: string;
+  id: string;
+  // start char (0-based)
   start: number;
+  // end char (0-based)
   end: number;
 }
 
@@ -62,40 +55,16 @@
   [side in Side]: LinesMap;
 };
 
-// Polymer 1 adds # before array's key, while Polymer 2 doesn't
-const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
-
 const RANGE_BASE_ONLY = 'style-scope gr-diff range';
 const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
+// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
 
-@customElement('gr-ranged-comment-layer')
-export class GrRangedCommentLayer extends PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRangedCommentLayer implements DiffLayer {
+  private knownRanges: CommentRangeLayer[] = [];
 
-  /**
-   * Fired when the range in a range comment was malformed and had to be
-   * normalized.
-   *
-   * It's `detail` has a `lineNum` and `side` parameter.
-   *
-   * @event normalize-range
-   */
+  private listeners: DiffLayerListener[] = [];
 
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array})
-  _listeners: DiffLayerListener[] = [];
-
-  @property({type: Object})
-  _rangesMap: RangesMap = {left: {}, right: {}};
-
-  get styleModuleName() {
-    return 'gr-ranged-comment-styles';
-  }
+  private rangesMap: RangesMap = {left: {}, right: {}};
 
   /**
    * Layer method to add annotations to a line.
@@ -107,16 +76,16 @@
     if (
       line.type === GrDiffLineType.REMOVE ||
       (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'right')
+        el.getAttribute('data-side') !== Side.RIGHT)
     ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.LEFT));
+      ranges = this.getRangesForLine(line, Side.LEFT);
     }
     if (
       line.type === GrDiffLineType.ADD ||
       (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'left')
+        el.getAttribute('data-side') !== Side.LEFT)
     ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.RIGHT));
+      ranges = this.getRangesForLine(line, Side.RIGHT);
     }
 
     for (const range of ranges) {
@@ -124,11 +93,8 @@
         el,
         range.start,
         range.end - range.start,
-        (range.hovering
-          ? HOVER_HIGHLIGHT
-          : range.longRange
-          ? RANGE_BASE_ONLY
-          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.rootId)}`
+        (range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.id)}`
       );
     }
   }
@@ -137,175 +103,106 @@
    * Register a listener for layer updates.
    */
   addListener(listener: DiffLayerListener) {
-    this._listeners.push(listener);
+    this.listeners.push(listener);
   }
 
   removeListener(listener: DiffLayerListener) {
-    this._listeners = this._listeners.filter(f => f !== listener);
+    this.listeners = this.listeners.filter(f => f !== listener);
   }
 
   /**
    * Notify Layer listeners of changes to annotations.
    */
-  _notifyUpdateRange(start: number, end: number, side: Side) {
-    for (const listener of this._listeners) {
+  private notifyUpdateRange(start: number, end: number, side: Side) {
+    for (const listener of this.listeners) {
       listener(start, end, side);
     }
   }
 
-  /**
-   * Handle change in the ranges by updating the ranges maps and by
-   * emitting appropriate update notifications.
-   */
-  @observe('commentRanges.*')
-  _handleCommentRangesChange(
-    record: PolymerDeepPropertyChange<
-      CommentRangeLayer[],
-      PolymerSpliceChange<CommentRangeLayer[]>
-    >
-  ) {
-    if (!record) return;
-
-    // If the entire set of comments was changed.
-    if (record.path === 'commentRanges') {
-      const value = record.value as CommentRangeLayer[];
-      this._rangesMap = {left: {}, right: {}};
-      for (const {side, range, rootId, hovering} of value) {
-        const longRange = isLongCommentRange(range);
-        this._updateRangesMap({
-          side,
-          range,
-          hovering,
-          operation: (forLine, start, end, hovering) => {
-            forLine.push({start, end, hovering, rootId, longRange});
-          },
-        });
-      }
+  updateRanges(newRanges: CommentRangeLayer[]) {
+    for (const newRange of newRanges) {
+      if (this.knownRanges.some(equals(newRange))) continue;
+      this.addRange(newRange);
     }
 
-    // If the change only changed the `hovering` property of a comment.
-    const match = record.path.match(HOVER_PATH_PATTERN);
-    if (match) {
-      // The #number indicates the key of that item in the array
-      // not the index, especially in polymer 1.
-      const {side, range, hovering, rootId} = this.get(match[1]);
-
-      this._updateRangesMap({
-        side,
-        range,
-        hovering,
-        skipLayerUpdate: true,
-        operation: (forLine, start, end, hovering) => {
-          const index = forLine.findIndex(
-            lineRange => lineRange.start === start && lineRange.end === end
-          );
-          forLine[index].hovering = hovering;
-          forLine[index].rootId = rootId;
-        },
-      });
+    for (const knownRange of this.knownRanges) {
+      if (newRanges.some(equals(knownRange))) continue;
+      this.removeRange(knownRange);
     }
 
-    // If comments were spliced in or out.
-    if (record.path === 'commentRanges.splices') {
-      const value = record.value as PolymerSpliceChange<CommentRangeLayer[]>;
-      for (const indexSplice of value.indexSplices) {
-        const removed = indexSplice.removed;
-        for (const {side, range, hovering, rootId} of removed) {
-          this._updateRangesMap({
-            side,
-            range,
-            hovering,
-            operation: (forLine, start, end) => {
-              const index = forLine.findIndex(
-                lineRange =>
-                  lineRange.start === start &&
-                  lineRange.end === end &&
-                  rootId === lineRange.rootId
-              );
-              forLine.splice(index, 1);
-            },
-          });
-        }
-        const added = indexSplice.object.slice(
-          indexSplice.index,
-          indexSplice.index + indexSplice.addedCount
-        );
-        for (const {side, range, hovering, rootId} of added) {
-          const longRange = isLongCommentRange(range);
-          this._updateRangesMap({
-            side,
-            range,
-            hovering,
-            operation: (forLine, start, end, hovering) => {
-              forLine.push({start, end, hovering, rootId, longRange});
-            },
-          });
-        }
-      }
-    }
+    this.knownRanges = [...newRanges];
   }
 
-  _updateRangesMap(options: {
+  private addRange(commentRange: CommentRangeLayer) {
+    const {side, range} = commentRange;
+    const longRange = isLongCommentRange(range);
+    this.updateRangesMap({
+      side,
+      range,
+      operation: (forLine, startChar, endChar) => {
+        forLine.push({
+          start: startChar,
+          end: endChar,
+          id: id(commentRange),
+          longRange,
+        });
+      },
+    });
+  }
+
+  private removeRange(commentRange: CommentRangeLayer) {
+    const {side, range} = commentRange;
+    this.updateRangesMap({
+      side,
+      range,
+      operation: forLine => {
+        const index = forLine.findIndex(
+          lineRange => id(commentRange) === lineRange.id
+        );
+        if (index > -1) forLine.splice(index, 1);
+      },
+    });
+  }
+
+  private updateRangesMap(options: {
     side: Side;
     range: CommentRange;
-    hovering: boolean;
     operation: (
       forLine: CommentRangeLineLayer[],
       start: number,
-      end: number,
-      hovering: boolean
+      end: number
     ) => void;
-    skipLayerUpdate?: boolean;
   }) {
-    const {side, range, hovering, operation, skipLayerUpdate} = options;
-    const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+    const {side, range, operation} = options;
+    const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
     for (let line = range.start_line; line <= range.end_line; line++) {
       const forLine = forSide[line] || (forSide[line] = []);
       const start = line === range.start_line ? range.start_character : 0;
       const end = line === range.end_line ? range.end_character : -1;
-      operation(forLine, start, end, hovering);
+      operation(forLine, start, end);
     }
-    if (!skipLayerUpdate) {
-      this._notifyUpdateRange(range.start_line, range.end_line, side);
-    }
+    this.notifyUpdateRange(range.start_line, range.end_line, side);
   }
 
-  _getRangesForLine(line: GrDiffLine, side: Side) {
+  // visible for testing
+  getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
     const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    const ranges: CommentRangeLineLayer[] =
-      this.get(['_rangesMap', side, lineNum]) || [];
-    return (
-      ranges
-        .map(range => {
-          // Make a copy, so that the normalization below does not mess with
-          // our map.
-          range = {...range};
-          range.end = range.end === -1 ? line.text.length : range.end;
+    if (lineNum === 'FILE' || lineNum === 'LOST') return [];
+    const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
+    return ranges.map(range => {
+      // Make a copy, so that the normalization below does not mess with
+      // our map.
+      range = {...range};
+      range.end = range.end === -1 ? line.text.length : range.end;
 
-          // Normalize invalid ranges where the start is after the end but the
-          // start still makes sense. Set the end to the end of the line.
-          // @see Issue 5744
-          if (range.start! >= range.end! && range.start! < line.text.length) {
-            range.end = line.text.length;
-            this.dispatchEvent(
-              new CustomEvent('normalize-range', {
-                bubbles: true,
-                composed: true,
-                detail: {lineNum, side},
-              })
-            );
-          }
+      // Normalize invalid ranges where the start is after the end but the
+      // start still makes sense. Set the end to the end of the line.
+      // @see Issue 5744
+      if (range.start >= range.end && range.start < line.text.length) {
+        range.end = line.text.length;
+      }
 
-          return range;
-        })
-        // Sort the ranges so that hovering highlights are on top.
-        .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
-    );
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-ranged-comment-layer': GrRangedCommentLayer;
+      return range;
+    });
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
deleted file mode 100644
index 8279ab1..0000000
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
+++ /dev/null
@@ -1,353 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-line.js';
-import './gr-ranged-comment-layer.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-
-const basicFixture = fixtureFromElement('gr-ranged-comment-layer');
-
-suite('gr-ranged-comment-layer', () => {
-  let element;
-
-  setup(() => {
-    const initialCommentRanges = [
-      {
-        side: 'left',
-        range: {
-          end_character: 9,
-          end_line: 39,
-          start_character: 6,
-          start_line: 36,
-        },
-        rootId: 'a',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 22,
-          end_line: 12,
-          start_character: 10,
-          start_line: 10,
-        },
-        rootId: 'b',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 15,
-          end_line: 100,
-          start_character: 5,
-          start_line: 100,
-        },
-        rootId: 'c',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 2,
-          end_line: 55,
-          start_character: 32,
-          start_line: 55,
-        },
-        rootId: 'd',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 1,
-          end_line: 71,
-          start_character: 1,
-          start_line: 60,
-        },
-      },
-    ];
-
-    element = basicFixture.instantiate();
-    element.commentRanges = initialCommentRanges;
-  });
-
-  suite('annotate', () => {
-    let el;
-    let line;
-    let annotateElementStub;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      el = document.createElement('div');
-      el.setAttribute('data-side', 'left');
-      line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
-    });
-
-    test('type=Remove no-comment', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 40;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Remove has-comment', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 36;
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHighlight generated_a'
-      );
-    });
-
-    test('type=Remove has-comment hovering', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 36;
-      element.set(['commentRanges', 0, 'hovering'], true);
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHoverHighlight generated_a'
-      );
-    });
-
-    test('type=Both has-comment', () => {
-      line.type = GrDiffLineType.BOTH;
-      line.beforeNumber = 36;
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHighlight generated_a'
-      );
-    });
-
-    test('type=Both has-comment off side', () => {
-      line.type = GrDiffLineType.BOTH;
-      line.beforeNumber = 36;
-      el.setAttribute('data-side', 'right');
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Add has-comment', () => {
-      line.type = GrDiffLineType.ADD;
-      line.afterNumber = 12;
-      el.setAttribute('data-side', 'right');
-
-      const expectedStart = 0;
-      const expectedLength = 22;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHighlight generated_b'
-      );
-    });
-
-    test('long range comment', () => {
-      line.type = GrDiffLineType.ADD;
-      line.afterNumber = 65;
-      el.setAttribute('data-side', 'right');
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(
-          annotateElementStub.lastCall.args[3],
-          'style-scope gr-diff range generated_'
-      );
-    });
-  });
-
-  test('_handleCommentRangesChange overwrite', () => {
-    element.set('commentRanges', []);
-
-    assert.equal(Object.keys(element._rangesMap.left).length, 0);
-    assert.equal(Object.keys(element._rangesMap.right).length, 0);
-  });
-
-  test('_handleCommentRangesChange hovering', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-
-    // notify will be skipped for hovering
-    assert.isFalse(notifyStub.called);
-
-    assert.isTrue(updateRangesMapSpy.called);
-  });
-
-  test('_handleCommentRangesChange splice out', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 1);
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 10);
-    assert.equal(lastCall.args[1], 12);
-    assert.equal(lastCall.args[2], 'right');
-  });
-
-  test('_handleCommentRangesChange splice in', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 250);
-    assert.equal(lastCall.args[1], 275);
-    assert.equal(lastCall.args[2], 'left');
-  });
-
-  test('_handleCommentRangesChange mixed actions', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 1);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 2);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 3);
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-    assert.isTrue(updateRangesMapSpy.callCount === 4);
-    element.set(['commentRanges', 2, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 5);
-  });
-
-  test('_computeCommentMap creates maps correctly', () => {
-    // There is only one ranged comment on the left, but it spans ll.36-39.
-    const leftKeys = [];
-    for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-    assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
-        leftKeys.sort());
-
-    assert.equal(element._rangesMap.left[36].length, 1);
-    assert.equal(element._rangesMap.left[36][0].start, 6);
-    assert.equal(element._rangesMap.left[36][0].end, -1);
-
-    assert.equal(element._rangesMap.left[37].length, 1);
-    assert.equal(element._rangesMap.left[37][0].start, 0);
-    assert.equal(element._rangesMap.left[37][0].end, -1);
-
-    assert.equal(element._rangesMap.left[38].length, 1);
-    assert.equal(element._rangesMap.left[38][0].start, 0);
-    assert.equal(element._rangesMap.left[38][0].end, -1);
-
-    assert.equal(element._rangesMap.left[39].length, 1);
-    assert.equal(element._rangesMap.left[39][0].start, 0);
-    assert.equal(element._rangesMap.left[39][0].end, 9);
-
-    // The right has four ranged comments: 10-12, 55-55, 60-71, 100-100
-    const rightKeys = [];
-    for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
-    for (let i = 60; i <= 71; i++) { rightKeys.push('' + i); }
-    rightKeys.push('55', '100');
-    assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
-        rightKeys.sort());
-
-    assert.equal(element._rangesMap.right[10].length, 1);
-    assert.equal(element._rangesMap.right[10][0].start, 10);
-    assert.equal(element._rangesMap.right[10][0].end, -1);
-
-    assert.equal(element._rangesMap.right[11].length, 1);
-    assert.equal(element._rangesMap.right[11][0].start, 0);
-    assert.equal(element._rangesMap.right[11][0].end, -1);
-
-    assert.equal(element._rangesMap.right[12].length, 1);
-    assert.equal(element._rangesMap.right[12][0].start, 0);
-    assert.equal(element._rangesMap.right[12][0].end, 22);
-
-    assert.equal(element._rangesMap.right[100].length, 1);
-    assert.equal(element._rangesMap.right[100][0].start, 5);
-    assert.equal(element._rangesMap.right[100][0].end, 15);
-  });
-
-  test('_getRangesForLine normalizes invalid ranges', () => {
-    const line = {
-      afterNumber: 55,
-      text: '_getRangesForLine normalizes invalid ranges',
-    };
-    const ranges = element._getRangesForLine(line, 'right');
-    assert.equal(ranges.length, 1);
-    const range = ranges[0];
-    assert.isTrue(range.start < range.end, 'start and end are normalized');
-    assert.equal(range.end, line.text.length);
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
new file mode 100644
index 0000000..15d14e3
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -0,0 +1,296 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-line';
+import './gr-ranged-comment-layer';
+import {
+  CommentRangeLayer,
+  GrRangedCommentLayer,
+} from './gr-ranged-comment-layer';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {Side} from '../../../api/diff';
+import {SinonStub} from 'sinon';
+
+const rangeA: CommentRangeLayer = {
+  side: Side.LEFT,
+  range: {
+    end_character: 9,
+    end_line: 39,
+    start_character: 6,
+    start_line: 36,
+  },
+  rootId: 'a',
+};
+
+const rangeB: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 22,
+    end_line: 12,
+    start_character: 10,
+    start_line: 10,
+  },
+  rootId: 'b',
+};
+
+const rangeC: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 15,
+    end_line: 100,
+    start_character: 5,
+    start_line: 100,
+  },
+};
+
+const rangeD: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 2,
+    end_line: 55,
+    start_character: 32,
+    start_line: 55,
+  },
+  rootId: 'd',
+};
+
+const rangeE: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 1,
+    end_line: 71,
+    start_character: 1,
+    start_line: 60,
+  },
+};
+
+suite('gr-ranged-comment-layer', () => {
+  let element: GrRangedCommentLayer;
+
+  setup(() => {
+    const initialCommentRanges: CommentRangeLayer[] = [
+      rangeA,
+      rangeB,
+      rangeC,
+      rangeD,
+      rangeE,
+    ];
+
+    element = new GrRangedCommentLayer();
+    element.updateRanges(initialCommentRanges);
+  });
+
+  suite('annotate', () => {
+    let el: HTMLDivElement;
+    let line: GrDiffLine;
+    let annotateElementStub: SinonStub;
+    const lineNumberEl = document.createElement('td');
+
+    function assertHasRange(
+      commentRange: CommentRangeLayer,
+      hasRange: boolean
+    ) {
+      assertHasRangeOn(
+        commentRange.side,
+        commentRange.range.start_line,
+        hasRange
+      );
+    }
+
+    function assertHasRangeOn(
+      side: Side,
+      lineNumber: number,
+      hasRange: boolean
+    ) {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      if (side === Side.LEFT) line.beforeNumber = lineNumber;
+      if (side === Side.RIGHT) line.afterNumber = lineNumber;
+      el.setAttribute('data-side', side);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.called, hasRange);
+      annotateElementStub.reset();
+    }
+
+    setup(() => {
+      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      el = document.createElement('div');
+      el.setAttribute('data-side', Side.LEFT);
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
+    });
+
+    test('type=Remove no-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.REMOVE);
+      line.beforeNumber = 40;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Remove has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.REMOVE);
+      line.beforeNumber = 36;
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'style-scope gr-diff range rangeHighlight generated_a'
+      );
+    });
+
+    test('type=Both has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 36;
+
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'style-scope gr-diff range rangeHighlight generated_a'
+      );
+    });
+
+    test('type=Both has-comment off side', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 36;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Add has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.ADD);
+      line.afterNumber = 12;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      const expectedStart = 0;
+      const expectedLength = 22;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'style-scope gr-diff range rangeHighlight generated_b'
+      );
+    });
+
+    test('long range comment', () => {
+      line = new GrDiffLine(GrDiffLineType.ADD);
+      line.afterNumber = 65;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(
+        annotateElementStub.lastCall.args[3],
+        'style-scope gr-diff range generated_right-60-1-71-1'
+      );
+    });
+
+    test('updateRanges remove all', () => {
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+
+      element.updateRanges([]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, false);
+      assertHasRange(rangeE, false);
+    });
+
+    test('updateRanges remove A and C', () => {
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+
+      element.updateRanges([rangeB, rangeD, rangeE]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+    });
+
+    test('updateRanges add B and D', () => {
+      element.updateRanges([]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, false);
+      assertHasRange(rangeE, false);
+
+      element.updateRanges([rangeB, rangeD]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, false);
+    });
+
+    test('updateRanges add A, remove B', () => {
+      element.updateRanges([rangeB, rangeC]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+
+      element.updateRanges([rangeA, rangeC]);
+
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, true);
+    });
+
+    test('_getRangesForLine normalizes invalid ranges', () => {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.afterNumber = 55;
+      line.text = 'getRangesForLine normalizes invalid ranges';
+      const ranges = element.getRangesForLine(line, Side.RIGHT);
+      assert.equal(ranges.length, 1);
+      const range = ranges[0];
+      assert.isTrue(range.start < range.end, 'start and end are normalized');
+      assert.equal(range.end, line.text.length);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index abbb0a3..bb6d1e9 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -1,26 +1,14 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
 import '../../../elements/shared/gr-tooltip/gr-tooltip';
 import {GrTooltip} from '../../../elements/shared/gr-tooltip/gr-tooltip';
-import {customElement, property} from '@polymer/decorators';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-selection-action-box_html';
 import {fireEvent} from '../../../utils/event-util';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -28,38 +16,64 @@
   }
 }
 
-export interface GrSelectionActionBox {
-  $: {
-    tooltip: GrTooltip;
-  };
-}
-
 @customElement('gr-selection-action-box')
-export class GrSelectionActionBox extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSelectionActionBox extends LitElement {
   /**
    * Fired when the comment creation action was taken (click).
    *
    * @event create-comment-requested
    */
 
+  @query('#tooltip')
+  tooltip?: GrTooltip;
+
   @property({type: Boolean})
   positionBelow = false;
 
+  /**
+   * We need to absolutely position the element before we can show it. So
+   * initially the tooltip must be invisible.
+   */
+  @state() private invisible = true;
+
   constructor() {
     super();
     // See https://crbug.com/gerrit/4767
-    this.addEventListener('mousedown', e => this._handleMouseDown(e));
+    this.addEventListener('mousedown', e => this.handleMouseDown(e));
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        cursor: pointer;
+        font-family: var(--font-family);
+        position: absolute;
+        white-space: nowrap;
+      }
+      gr-tooltip[invisible] {
+        visibility: hidden;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-tooltip
+        id="tooltip"
+        ?invisible=${this.invisible}
+        text="Press c to comment"
+        ?position-below=${this.positionBelow}
+      ></gr-tooltip>
+    `;
   }
 
   async placeAbove(el: Text | Element | Range) {
-    await this.$.tooltip.updateComplete;
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
+    if (!this.tooltip) return;
+    await this.tooltip.updateComplete;
+    const rect = this.getTargetBoundingRect(el);
+    const boxRect = this.tooltip.getBoundingClientRect();
+    const parentRect = this.getParentBoundingClientRect();
     if (parentRect === null) {
       return;
     }
@@ -67,13 +81,15 @@
     this.style.left = `${
       rect.left - parentRect.left + (rect.width - boxRect.width) / 2
     }px`;
+    this.invisible = false;
   }
 
   async placeBelow(el: Text | Element | Range) {
-    await this.$.tooltip.updateComplete;
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
+    if (!this.tooltip) return;
+    await this.tooltip.updateComplete;
+    const rect = this.getTargetBoundingRect(el);
+    const boxRect = this.tooltip.getBoundingClientRect();
+    const parentRect = this.getParentBoundingClientRect();
     if (parentRect === null) {
       return;
     }
@@ -81,9 +97,10 @@
     this.style.left = `${
       rect.left - parentRect.left + (rect.width - boxRect.width) / 2
     }px`;
+    this.invisible = false;
   }
 
-  private _getParentBoundingClientRect() {
+  private getParentBoundingClientRect() {
     // With native shadow DOM, the parent is the shadow root, not the gr-diff
     // element
     if (this.parentElement) {
@@ -95,8 +112,8 @@
     return null;
   }
 
-  // private but used in test
-  _getTargetBoundingRect(el: Text | Element | Range) {
+  // visible for testing
+  getTargetBoundingRect(el: Text | Element | Range) {
     let rect;
     if (el instanceof Text) {
       const range = document.createRange();
@@ -109,8 +126,8 @@
     return rect;
   }
 
-  // private but used in test
-  _handleMouseDown(e: MouseEvent) {
+  // visible for testing
+  handleMouseDown(e: MouseEvent) {
     if (e.button !== 0) {
       return;
     } // 0 = main button
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_html.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_html.ts
deleted file mode 100644
index 24d63b3..0000000
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_html.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      cursor: pointer;
-      font-family: var(--font-family);
-      position: absolute;
-      white-space: nowrap;
-    }
-  </style>
-  <gr-tooltip
-    id="tooltip"
-    text="Press c to comment"
-    position-below="[[positionBelow]]"
-  ></gr-tooltip>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
index 30b7ded..a92c967 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -1,51 +1,44 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-selection-action-box';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {GrSelectionActionBox} from './gr-selection-action-box';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromTemplate(html`
-  <div>
-    <gr-selection-action-box></gr-selection-action-box>
-    <div class="target">some text</div>
-  </div>
-`);
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-selection-action-box', () => {
-  let container: GrSelectionActionBox;
+  let container: HTMLDivElement;
   let element: GrSelectionActionBox;
   let dispatchEventStub: sinon.SinonStub;
 
-  setup(() => {
-    container = basicFixture.instantiate() as GrSelectionActionBox;
+  setup(async () => {
+    container = await fixture<HTMLDivElement>(html`
+      <div>
+        <gr-selection-action-box></gr-selection-action-box>
+        <div class="target">some text</div>
+      </div>
+    `);
     element = queryAndAssert<GrSelectionActionBox>(
       container,
       'gr-selection-action-box'
     );
+    await element.updateComplete;
 
     dispatchEventStub = sinon.stub(element, 'dispatchEvent');
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-tooltip invisible id="tooltip" text="Press c to comment"></gr-tooltip>
+    `);
+  });
+
   test('ignores regular keys', () => {
-    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
+    const event = new KeyboardEvent('keydown', {key: 'a'});
+    document.body.dispatchEvent(event);
     assert.isFalse(dispatchEventStub.called);
   });
 
@@ -61,7 +54,7 @@
     });
 
     test('event handled if main button', () => {
-      element._handleMouseDown(e);
+      element.handleMouseDown(e);
       assert.isTrue(e.preventDefault.called);
       assert.equal(
         dispatchEventStub.lastCall.args[0].type,
@@ -71,7 +64,7 @@
 
     test('event ignored if not main button', () => {
       e.button = 1;
-      element._handleMouseDown(e);
+      element.handleMouseDown(e);
       assert.isFalse(e.preventDefault.called);
       assert.isFalse(dispatchEventStub.called);
     });
@@ -92,7 +85,7 @@
         height: 6,
       } as DOMRect);
       getTargetBoundingRectStub = sinon
-        .stub(element, '_getTargetBoundingRect')
+        .stub(element, 'getTargetBoundingRect')
         .returns({
           top: 42,
           bottom: 20,
@@ -101,11 +94,20 @@
           width: 100,
           height: 60,
         } as DOMRect);
+      assert.isOk(element.tooltip);
       sinon
-        .stub(element.$.tooltip, 'getBoundingClientRect')
+        .stub(element.tooltip!, 'getBoundingClientRect')
         .returns({width: 10, height: 10} as DOMRect);
     });
 
+    test('renders visible', async () => {
+      await element.placeAbove(target);
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-tooltip id="tooltip" text="Press c to comment"></gr-tooltip>
+      `);
+    });
+
     test('placeAbove for Element argument', async () => {
       await element.placeAbove(target);
       assert.equal(element.style.top, '25px');
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index ba64f27..deb075e 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -8,9 +8,92 @@
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {LANGUAGE_MAP} from './gr-syntax-layer';
 import {getAppContext} from '../../../services/app-context';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
+
+const LANGUAGE_MAP = new Map<string, string>([
+  ['application/dart', 'dart'],
+  ['application/json', 'json'],
+  ['application/x-powershell', 'powershell'],
+  ['application/typescript', 'typescript'],
+  ['application/xml', 'xml'],
+  ['application/xquery', 'xquery'],
+  ['application/x-erb', 'erb'],
+  ['text/css', 'css'],
+  ['text/html', 'html'],
+  ['text/javascript', 'js'],
+  ['text/jsx', 'jsx'],
+  ['text/tsx', 'jsx'],
+  ['text/x-c', 'cpp'],
+  ['text/x-c++src', 'cpp'],
+  ['text/x-clojure', 'clojure'],
+  ['text/x-cmake', 'cmake'],
+  ['text/x-coffeescript', 'coffeescript'],
+  ['text/x-common-lisp', 'lisp'],
+  ['text/x-crystal', 'crystal'],
+  ['text/x-csharp', 'csharp'],
+  ['text/x-csrc', 'cpp'],
+  ['text/x-d', 'd'],
+  ['text/x-diff', 'diff'],
+  ['text/x-django', 'django'],
+  ['text/x-dockerfile', 'dockerfile'],
+  ['text/x-ebnf', 'ebnf'],
+  ['text/x-elm', 'elm'],
+  ['text/x-erlang', 'erlang'],
+  ['text/x-fortran', 'fortran'],
+  ['text/x-fsharp', 'fsharp'],
+  ['text/x-gfm', 'markdown'],
+  ['text/x-gherkin', 'gherkin'],
+  ['text/x-go', 'go'],
+  ['text/x-groovy', 'groovy'],
+  ['text/x-haml', 'haml'],
+  ['text/x-handlebars', 'handlebars'],
+  ['text/x-haskell', 'haskell'],
+  ['text/x-haxe', 'haxe'],
+  ['text/x-iecst', 'iecst'],
+  ['text/x-ini', 'ini'],
+  ['text/x-java', 'java'],
+  ['text/x-julia', 'julia'],
+  ['text/x-kotlin', 'kotlin'],
+  ['text/x-latex', 'latex'],
+  ['text/x-less', 'less'],
+  ['text/x-lua', 'lua'],
+  ['text/x-markdown', 'markdown'],
+  ['text/x-mathematica', 'mathematica'],
+  ['text/x-nginx-conf', 'nginx'],
+  ['text/x-nsis', 'nsis'],
+  ['text/x-objectivec', 'objectivec'],
+  ['text/x-ocaml', 'ocaml'],
+  ['text/x-perl', 'perl'],
+  ['text/x-pgsql', 'pgsql'], // postgresql
+  ['text/x-php', 'php'],
+  ['text/x-properties', 'properties'],
+  ['text/x-protobuf', 'protobuf'],
+  ['text/x-puppet', 'puppet'],
+  ['text/x-python', 'python'],
+  ['text/x-q', 'q'],
+  ['text/x-ruby', 'ruby'],
+  ['text/x-rustsrc', 'rust'],
+  ['text/x-scala', 'scala'],
+  ['text/x-scss', 'scss'],
+  ['text/x-scheme', 'scheme'],
+  ['text/x-shell', 'shell'],
+  ['text/x-soy', 'soy'],
+  ['text/x-spreadsheet', 'excel'],
+  ['text/x-sh', 'bash'],
+  ['text/x-sql', 'sql'],
+  ['text/x-swift', 'swift'],
+  ['text/x-systemverilog', 'sv'],
+  ['text/x-tcl', 'tcl'],
+  ['text/x-torque', 'torque'],
+  ['text/x-twig', 'twig'],
+  ['text/x-vb', 'vb'],
+  ['text/x-verilog', 'v'],
+  ['text/x-vhdl', 'vhdl'],
+  ['text/x-yaml', 'yaml'],
+  ['text/vbscript', 'vbscript'],
+]);
 
 const CLASS_PREFIX = 'gr-diff gr-syntax gr-syntax-';
 
@@ -18,8 +101,12 @@
   'attr',
   'attribute',
   'built_in',
+  'bullet',
+  'code',
   'comment',
   'doctag',
+  'emphasis',
+  'formula',
   'function',
   'keyword',
   'link',
@@ -30,19 +117,24 @@
   'number',
   'params',
   'property',
+  'quote',
   'regexp',
+  'section',
   'selector-attr',
   'selector-class',
   'selector-id',
   'selector-pseudo',
   'selector-tag',
   'string',
+  'strong',
   'tag',
   'template-tag',
   'template-variable',
   'title',
+  'title function_',
   'type',
   'variable',
+  'variable language_',
 ]);
 
 export class GrSyntaxLayerWorker implements DiffLayer {
@@ -50,19 +142,29 @@
 
   enabled = true;
 
-  private leftRanges: SyntaxLayerLine[] = [];
+  // private, but visible for testing
+  leftRanges: SyntaxLayerLine[] = [];
 
-  private rightRanges: SyntaxLayerLine[] = [];
+  // private, but visible for testing
+  rightRanges: SyntaxLayerLine[] = [];
+
+  /**
+   * We are keeping a reference around to the async computation, such that we
+   * can cancel it, if needed.
+   */
+  private leftPromise?: CancelablePromise<SyntaxLayerLine[]>;
+
+  /**
+   * We are keeping a reference around to the async computation, such that we
+   * can cancel it, if needed.
+   */
+  private rightPromise?: CancelablePromise<SyntaxLayerLine[]>;
 
   private listeners: DiffLayerListener[] = [];
 
   private readonly highlightService = getAppContext().highlightService;
 
-  init(diff?: DiffInfo) {
-    this.leftRanges = [];
-    this.rightRanges = [];
-    this.diff = diff;
-  }
+  private readonly reportingService = getAppContext().reportingService;
 
   setEnabled(enabled: boolean) {
     this.enabled = enabled;
@@ -103,6 +205,7 @@
 
     for (const range of ranges) {
       if (!CLASS_SAFELIST.has(range.className)) continue;
+      if (range.length === 0) continue;
       GrAnnotation.annotateElement(
         el,
         range.start,
@@ -126,20 +229,19 @@
    * For larger files this is an expensive operation, but is offloaded to a web
    * worker. We are using the HighlightJS lib for doing the actual highlighting.
    *
-   * `init()` must have been called before. There is actually no good reason for
-   * init() and process() to be separated. The diff host typically allows the
-   * diff builder to render first and only then calls process(), but as soon as
-   * the diff is known the highlighting process can be kicked off.
-   * TODO(brohlfs): Call process() directly after init().
-   *
    * annotate() will only be able to apply highlighting after process() has
    * completed, but that will likely happen later. That is why layer can have
    * listeners. When process() completes, the listeners will be notified, which
    * tells the diff renderer that another call to annotate() is needed.
    */
-  async process() {
+  async process(diff: DiffInfo) {
+    this.diff = diff;
     this.leftRanges = [];
     this.rightRanges = [];
+    if (this.leftPromise) this.leftPromise.cancel();
+    if (this.rightPromise) this.rightPromise.cancel();
+    this.leftPromise = undefined;
+    this.rightPromise = undefined;
     if (!this.enabled || !this.diff) return;
 
     const leftLanguage = this._getLanguage(this.diff.meta_a);
@@ -149,25 +251,43 @@
     let rightContent = '';
     for (const chunk of this.diff.content) {
       const a = [...(chunk.a ?? []), ...(chunk.ab ?? [])];
-      const b = [...(chunk.b ?? []), ...(chunk.ab ?? [])];
       for (const line of a) {
         leftContent += line + '\n';
       }
+      const b = [...(chunk.b ?? []), ...(chunk.ab ?? [])];
       for (const line of b) {
         rightContent += line + '\n';
       }
+      const skip = chunk.skip ?? 0;
+      if (skip > 0) {
+        leftContent += '\n'.repeat(skip);
+        rightContent += '\n'.repeat(skip);
+      }
     }
+    leftContent = leftContent.trimEnd();
+    rightContent = rightContent.trimEnd();
 
-    const leftPromise = this.highlight(leftLanguage, leftContent);
-    const rightPromise = this.highlight(rightLanguage, rightContent);
-    this.leftRanges = await leftPromise;
-    this.rightRanges = await rightPromise;
-    this.notify();
+    try {
+      this.leftPromise = this.highlight(leftLanguage, leftContent);
+      this.rightPromise = this.highlight(rightLanguage, rightContent);
+      this.leftRanges = await this.leftPromise;
+      this.rightRanges = await this.rightPromise;
+      this.notify();
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    } catch (err: any) {
+      if (!err.isCanceled) this.reportingService.error(err as Error);
+      // One source of "error" can promise cancelation.
+      this.leftRanges = [];
+      this.rightRanges = [];
+    }
   }
 
-  async highlight(language?: string, code?: string) {
-    if (!language || !code) return [];
-    return this.highlightService.highlight(language, code);
+  highlight(
+    language?: string,
+    code?: string
+  ): CancelablePromise<SyntaxLayerLine[]> {
+    const hlPromise = this.highlightService.highlight(language, code);
+    return makeCancelable(hlPromise);
   }
 
   notify() {
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index 9c2cc9d..ee82d5d 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -5,6 +5,7 @@
  */
 import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
 import '../../../test/common-test-setup-karma';
+import {mockPromise, stubHighlightService} from '../../../test/test-utils';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrSyntaxLayerWorker} from './gr-syntax-layer-worker';
@@ -75,41 +76,78 @@
 
   setup(() => {
     layer = new GrSyntaxLayerWorker();
-    listener = sinon.stub();
-    layer.addListener(listener);
-    sinon.stub(layer, 'highlight').callsFake((lang?: string) => {
-      if (lang === 'lang-left') return Promise.resolve(leftRanges);
-      if (lang === 'lang-right') return Promise.resolve(rightRanges);
-      return Promise.resolve([]);
+  });
+
+  test('cancel processing', async () => {
+    const mockPromise1 = mockPromise<SyntaxLayerLine[]>();
+    const mockPromise2 = mockPromise<SyntaxLayerLine[]>();
+    const mockPromise3 = mockPromise<SyntaxLayerLine[]>();
+    const mockPromise4 = mockPromise<SyntaxLayerLine[]>();
+    const stub = stubHighlightService('highlight');
+    stub.onCall(0).returns(mockPromise1);
+    stub.onCall(1).returns(mockPromise2);
+    stub.onCall(2).returns(mockPromise3);
+    stub.onCall(3).returns(mockPromise4);
+
+    const processPromise1 = layer.process(diff);
+    // Calling the process() a second time means that the promises created
+    // during the first call are cancelled.
+    const processPromise2 = layer.process(diff);
+    // We can await the outer promise even before the inner promises resolve,
+    // because cancelling rejects the inner promises.
+    await processPromise1;
+    // It does not matter actually, whether these two inner promises are
+    // resolved or not.
+    mockPromise1.resolve(leftRanges);
+    mockPromise2.resolve(rightRanges);
+    // Both ranges must still be empty, because the promise of the first call
+    // must have been cancelled and the returned ranges ignored.
+    assert.isEmpty(layer.leftRanges);
+    assert.isEmpty(layer.rightRanges);
+    // Lets' resolve and await the promises of the second as normal.
+    mockPromise3.resolve(leftRanges);
+    mockPromise4.resolve(rightRanges);
+    await processPromise2;
+    assert.equal(layer.leftRanges, leftRanges);
+  });
+
+  suite('annotate and listen', () => {
+    setup(() => {
+      listener = sinon.stub();
+      layer.addListener(listener);
+      stubHighlightService('highlight').callsFake((lang?: string) => {
+        if (lang === 'lang-left') return Promise.resolve(leftRanges);
+        if (lang === 'lang-right') return Promise.resolve(rightRanges);
+        return Promise.resolve([]);
+      });
     });
-    layer.init(diff);
-  });
 
-  test('process and annotate line 2 LEFT', async () => {
-    await layer.process();
-    const el = annotate(Side.LEFT, 1, 'import it;');
-    assert.equal(
-      el.innerHTML,
-      '<hl class="gr-diff gr-syntax gr-syntax-literal">import</hl> it;'
-    );
-    assert.equal(listener.callCount, 2);
-    assert.equal(listener.getCall(0).args[0], 1);
-    assert.equal(listener.getCall(0).args[1], 1);
-    assert.equal(listener.getCall(0).args[2], Side.LEFT);
-    assert.equal(listener.getCall(1).args[0], 3);
-    assert.equal(listener.getCall(1).args[1], 3);
-    assert.equal(listener.getCall(1).args[2], Side.RIGHT);
-  });
+    test('process and annotate line 2 LEFT', async () => {
+      await layer.process(diff);
+      const el = annotate(Side.LEFT, 1, 'import it;');
+      assert.equal(
+        el.innerHTML,
+        '<hl class="gr-diff gr-syntax gr-syntax-literal">import</hl> it;'
+      );
+      assert.equal(listener.callCount, 2);
+      assert.equal(listener.getCall(0).args[0], 1);
+      assert.equal(listener.getCall(0).args[1], 1);
+      assert.equal(listener.getCall(0).args[2], Side.LEFT);
+      assert.equal(listener.getCall(1).args[0], 3);
+      assert.equal(listener.getCall(1).args[1], 3);
+      assert.equal(listener.getCall(1).args[2], Side.RIGHT);
+    });
 
-  test('process and annotate line 3 RIGHT', async () => {
-    await layer.process();
-    const el = annotate(Side.RIGHT, 3, '  public static final {');
-    assert.equal(
-      el.innerHTML,
-      '  <hl class="gr-diff gr-syntax gr-syntax-literal">public</hl> ' +
-        '<hl class="gr-diff gr-syntax gr-syntax-keyword">static</hl> ' +
-        '<hl class="gr-diff gr-syntax gr-syntax-name">final</hl> {'
-    );
-    assert.equal(listener.callCount, 2);
+    test('process and annotate line 3 RIGHT', async () => {
+      await layer.process(diff);
+      const el = annotate(Side.RIGHT, 3, '  public static final {');
+      assert.equal(
+        el.innerHTML,
+        '  <hl class="gr-diff gr-syntax gr-syntax-literal">public</hl> ' +
+          '<hl class="gr-diff gr-syntax gr-syntax-keyword">static</hl> ' +
+          '<hl class="gr-diff gr-syntax gr-syntax-name">final</hl> {'
+      );
+      assert.equal(listener.callCount, 2);
+    });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer.ts
deleted file mode 100644
index 8838fe6..0000000
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ /dev/null
@@ -1,593 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
-import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
-import {GrLibLoader} from '../../../elements/shared/gr-lib-loader/gr-lib-loader';
-import {HLJS_LIBRARY_CONFIG} from '../../../elements/shared/gr-lib-loader/highlightjs_config';
-import {Side} from '../../../constants/constants';
-
-export const LANGUAGE_MAP = new Map<string, string>([
-  ['application/dart', 'dart'],
-  ['application/json', 'json'],
-  ['application/x-powershell', 'powershell'],
-  ['application/typescript', 'typescript'],
-  ['application/xml', 'xml'],
-  ['application/xquery', 'xquery'],
-  ['application/x-erb', 'erb'],
-  ['text/css', 'css'],
-  ['text/html', 'html'],
-  ['text/javascript', 'js'],
-  ['text/jsx', 'jsx'],
-  ['text/tsx', 'jsx'],
-  ['text/x-c', 'cpp'],
-  ['text/x-c++src', 'cpp'],
-  ['text/x-clojure', 'clojure'],
-  ['text/x-cmake', 'cmake'],
-  ['text/x-coffeescript', 'coffeescript'],
-  ['text/x-common-lisp', 'lisp'],
-  ['text/x-crystal', 'crystal'],
-  ['text/x-csharp', 'csharp'],
-  ['text/x-csrc', 'cpp'],
-  ['text/x-d', 'd'],
-  ['text/x-diff', 'diff'],
-  ['text/x-django', 'django'],
-  ['text/x-dockerfile', 'dockerfile'],
-  ['text/x-ebnf', 'ebnf'],
-  ['text/x-elm', 'elm'],
-  ['text/x-erlang', 'erlang'],
-  ['text/x-fortran', 'fortran'],
-  ['text/x-fsharp', 'fsharp'],
-  ['text/x-gherkin', 'gherkin'],
-  ['text/x-go', 'go'],
-  ['text/x-groovy', 'groovy'],
-  ['text/x-haml', 'haml'],
-  ['text/x-handlebars', 'handlebars'],
-  ['text/x-haskell', 'haskell'],
-  ['text/x-haxe', 'haxe'],
-  ['text/x-iecst', 'iecst'],
-  ['text/x-ini', 'ini'],
-  ['text/x-java', 'java'],
-  ['text/x-julia', 'julia'],
-  ['text/x-kotlin', 'kotlin'],
-  ['text/x-latex', 'latex'],
-  ['text/x-less', 'less'],
-  ['text/x-lua', 'lua'],
-  ['text/x-mathematica', 'mathematica'],
-  ['text/x-nginx-conf', 'nginx'],
-  ['text/x-nsis', 'nsis'],
-  ['text/x-objectivec', 'objectivec'],
-  ['text/x-ocaml', 'ocaml'],
-  ['text/x-perl', 'perl'],
-  ['text/x-pgsql', 'pgsql'], // postgresql
-  ['text/x-php', 'php'],
-  ['text/x-properties', 'properties'],
-  ['text/x-protobuf', 'protobuf'],
-  ['text/x-puppet', 'puppet'],
-  ['text/x-python', 'python'],
-  ['text/x-q', 'q'],
-  ['text/x-ruby', 'ruby'],
-  ['text/x-rustsrc', 'rust'],
-  ['text/x-scala', 'scala'],
-  ['text/x-scss', 'scss'],
-  ['text/x-scheme', 'scheme'],
-  ['text/x-shell', 'shell'],
-  ['text/x-soy', 'soy'],
-  ['text/x-spreadsheet', 'excel'],
-  ['text/x-sh', 'bash'],
-  ['text/x-sql', 'sql'],
-  ['text/x-swift', 'swift'],
-  ['text/x-systemverilog', 'sv'],
-  ['text/x-tcl', 'tcl'],
-  ['text/x-torque', 'torque'],
-  ['text/x-twig', 'twig'],
-  ['text/x-vb', 'vb'],
-  ['text/x-verilog', 'v'],
-  ['text/x-vhdl', 'vhdl'],
-  ['text/x-yaml', 'yaml'],
-  ['text/vbscript', 'vbscript'],
-]);
-const ASYNC_DELAY = 10;
-
-const CLASS_SAFELIST = new Set<string>([
-  'gr-diff gr-syntax gr-syntax-attr',
-  'gr-diff gr-syntax gr-syntax-attribute',
-  'gr-diff gr-syntax gr-syntax-built_in',
-  'gr-diff gr-syntax gr-syntax-comment',
-  'gr-diff gr-syntax gr-syntax-doctag',
-  'gr-diff gr-syntax gr-syntax-function',
-  'gr-diff gr-syntax gr-syntax-keyword',
-  'gr-diff gr-syntax gr-syntax-link',
-  'gr-diff gr-syntax gr-syntax-literal',
-  'gr-diff gr-syntax gr-syntax-meta',
-  'gr-diff gr-syntax gr-syntax-meta-keyword',
-  'gr-diff gr-syntax gr-syntax-name',
-  'gr-diff gr-syntax gr-syntax-number',
-  'gr-diff gr-syntax gr-syntax-params',
-  'gr-diff gr-syntax gr-syntax-property',
-  'gr-diff gr-syntax gr-syntax-regexp',
-  'gr-diff gr-syntax gr-syntax-selector-attr',
-  'gr-diff gr-syntax gr-syntax-selector-class',
-  'gr-diff gr-syntax gr-syntax-selector-id',
-  'gr-diff gr-syntax gr-syntax-selector-pseudo',
-  'gr-diff gr-syntax gr-syntax-selector-tag',
-  'gr-diff gr-syntax gr-syntax-string',
-  'gr-diff gr-syntax gr-syntax-tag',
-  'gr-diff gr-syntax gr-syntax-template-tag',
-  'gr-diff gr-syntax gr-syntax-template-variable',
-  'gr-diff gr-syntax gr-syntax-title',
-  'gr-diff gr-syntax gr-syntax-type',
-  'gr-diff gr-syntax gr-syntax-variable',
-]);
-
-const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-const CPP_WCHAR_PATTERN = /L'(\\)?.'/g;
-const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
-const GO_BACKSLASH_LITERAL = "'\\\\'";
-const GLOBAL_LT_PATTERN = /</g;
-
-interface SyntaxLayerRange {
-  start: number;
-  length: number;
-  className: string;
-}
-
-interface SyntaxLayerState {
-  sectionIndex: number;
-  lineIndex: number;
-  baseContext: unknown;
-  revisionContext: unknown;
-  lineNums: {left: number; right: number};
-  lastNotify: {left: number; right: number};
-}
-
-export class GrSyntaxLayer implements DiffLayer {
-  diff?: DiffInfo;
-
-  enabled = true;
-
-  private baseRanges: SyntaxLayerRange[][] = [];
-
-  private revisionRanges: SyntaxLayerRange[][] = [];
-
-  private baseLanguage?: string;
-
-  private revisionLanguage?: string;
-
-  private listeners: DiffLayerListener[] = [];
-
-  private processHandle: number | null = null;
-
-  private processPromise: CancelablePromise<unknown> | null = null;
-
-  private hljs?: HighlightJS;
-
-  private readonly libLoader = new GrLibLoader();
-
-  init(diff?: DiffInfo) {
-    this.cancel();
-    this.baseRanges = [];
-    this.revisionRanges = [];
-    this.diff = diff;
-  }
-
-  setEnabled(enabled: boolean) {
-    this.enabled = enabled;
-  }
-
-  addListener(listener: DiffLayerListener) {
-    this.listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this.listeners = this.listeners.filter(f => f !== listener);
-  }
-
-  /**
-   * Annotation layer method to add syntax annotations to the given element
-   * for the given line.
-   */
-  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-    if (!this.enabled) return;
-    if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
-    if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
-    // Determine the side.
-    let side;
-    if (
-      line.type === GrDiffLineType.REMOVE ||
-      (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'right')
-    ) {
-      side = 'left';
-    } else if (
-      line.type === GrDiffLineType.ADD ||
-      el.getAttribute('data-side') !== 'left'
-    ) {
-      side = 'right';
-    }
-
-    // Find the relevant syntax ranges, if any.
-    let ranges: SyntaxLayerRange[] = [];
-    if (side === 'left' && this.baseRanges.length >= line.beforeNumber) {
-      ranges = this.baseRanges[line.beforeNumber - 1] || [];
-    } else if (
-      side === 'right' &&
-      this.revisionRanges.length >= line.afterNumber
-    ) {
-      ranges = this.revisionRanges[line.afterNumber - 1] || [];
-    }
-
-    // Apply the ranges to the element.
-    for (const range of ranges) {
-      GrAnnotation.annotateElement(
-        el,
-        range.start,
-        range.length,
-        range.className
-      );
-    }
-  }
-
-  _getLanguage(metaInfo: DiffFileMetaInfo) {
-    // The Gerrit API provides only content-type, but for other users of
-    // gr-diff it may be more convenient to specify the language directly.
-    return metaInfo.language ?? LANGUAGE_MAP.get(metaInfo.content_type);
-  }
-
-  /**
-   * Start processing syntax for the loaded diff and notify layer listeners
-   * as syntax info comes online.
-   */
-  process() {
-    // Cancel any still running process() calls, because they append to the
-    // same baseRanges and revisionRanges fields.
-    this.cancel();
-
-    // Discard existing ranges.
-    this.baseRanges = [];
-    this.revisionRanges = [];
-
-    if (!this.enabled || !this.diff?.content.length) {
-      return Promise.resolve();
-    }
-
-    if (this.diff.meta_a) {
-      this.baseLanguage = this._getLanguage(this.diff.meta_a);
-    }
-    if (this.diff.meta_b) {
-      this.revisionLanguage = this._getLanguage(this.diff.meta_b);
-    }
-    if (!this.baseLanguage && !this.revisionLanguage) {
-      return Promise.resolve();
-    }
-
-    const state: SyntaxLayerState = {
-      sectionIndex: 0,
-      lineIndex: 0,
-      baseContext: undefined,
-      revisionContext: undefined,
-      lineNums: {left: 1, right: 1},
-      lastNotify: {left: 1, right: 1},
-    };
-
-    const rangesCache = new Map<string, SyntaxLayerRange[]>();
-
-    this.processPromise = util.makeCancelable(
-      this._loadHLJS().then(
-        () =>
-          new Promise<void>(resolve => {
-            const nextStep = () => {
-              this.processHandle = null;
-              this._processNextLine(state, rangesCache);
-
-              // Move to the next line in the section.
-              state.lineIndex++;
-
-              // If the section has been exhausted, move to the next one.
-              if (this._isSectionDone(state)) {
-                state.lineIndex = 0;
-                state.sectionIndex++;
-              }
-
-              // If all sections have been exhausted, finish.
-              if (
-                !this.diff ||
-                state.sectionIndex >= this.diff.content.length
-              ) {
-                resolve();
-                this._notify(state);
-                return;
-              }
-
-              if (state.lineIndex % 100 === 0) {
-                this._notify(state);
-                this.processHandle = window.setTimeout(nextStep, ASYNC_DELAY);
-              } else {
-                nextStep.call(this);
-              }
-            };
-
-            this.processHandle = window.setTimeout(nextStep, 1);
-          })
-      )
-    );
-    return this.processPromise.finally(() => {
-      this.processPromise = null;
-    });
-  }
-
-  /**
-   * Cancel any asynchronous syntax processing jobs.
-   */
-  cancel() {
-    if (this.processHandle !== null) {
-      clearTimeout(this.processHandle);
-      this.processHandle = null;
-    }
-    if (this.processPromise) {
-      this.processPromise.cancel();
-    }
-  }
-
-  /**
-   * Take a string of HTML with the (potentially nested) syntax markers
-   * Highlight.js emits and emit a list of text ranges and classes for the
-   * markers.
-   *
-   * @param str The string of HTML.
-   * @param rangesCache A map for caching
-   * ranges for each string. A cache is read and written by this method.
-   * Since diff is mostly comparing same file on two sides, there is good rate
-   * of duplication at least for parts that are on left and right parts.
-   * @return The list of ranges.
-   */
-  _rangesFromString(
-    str: string,
-    rangesCache: Map<string, SyntaxLayerRange[]>
-  ): SyntaxLayerRange[] {
-    const cached = rangesCache.get(str);
-    if (cached) return cached;
-
-    const div = document.createElement('div');
-    div.innerHTML = str;
-    const ranges = this._rangesFromElement(div, 0);
-    rangesCache.set(str, ranges);
-    return ranges;
-  }
-
-  _rangesFromElement(elem: Element, offset: number): SyntaxLayerRange[] {
-    let result: SyntaxLayerRange[] = [];
-    for (const node of elem.childNodes) {
-      const nodeLength = GrAnnotation.getLength(node);
-      // Note: HLJS may emit a span with class undefined when it thinks there
-      // may be a syntax error.
-      if (
-        node instanceof Element &&
-        node.tagName === 'SPAN' &&
-        node.className !== 'undefined'
-      ) {
-        if (CLASS_SAFELIST.has(node.className)) {
-          result.push({
-            start: offset,
-            length: nodeLength,
-            className: node.className,
-          });
-        }
-        if (node.children.length) {
-          result = result.concat(this._rangesFromElement(node, offset));
-        }
-      }
-      offset += nodeLength;
-    }
-    return result;
-  }
-
-  /**
-   * For a given state, process the syntax for the next line (or pair of
-   * lines).
-   */
-  _processNextLine(
-    state: SyntaxLayerState,
-    rangesCache: Map<string, SyntaxLayerRange[]>
-  ) {
-    if (!this.diff) return;
-    if (!this.hljs) return;
-
-    let baseLine;
-    let revisionLine;
-    const section = this.diff.content[state.sectionIndex];
-    if (section.ab) {
-      baseLine = section.ab[state.lineIndex];
-      revisionLine = section.ab[state.lineIndex];
-      state.lineNums.left++;
-      state.lineNums.right++;
-    } else {
-      if (section.a && section.a.length > state.lineIndex) {
-        baseLine = section.a[state.lineIndex];
-        state.lineNums.left++;
-      }
-      if (section.b && section.b.length > state.lineIndex) {
-        revisionLine = section.b[state.lineIndex];
-        state.lineNums.right++;
-      }
-      if (section.skip) {
-        state.lineNums.left += section.skip;
-        state.lineNums.right += section.skip;
-        for (let i = 0; i < section.skip; i++) this.revisionRanges.push([]);
-      }
-    }
-
-    // To store the result of the syntax highlighter.
-    let result;
-
-    if (
-      this.baseLanguage &&
-      baseLine !== undefined &&
-      this.hljs.getLanguage(this.baseLanguage)
-    ) {
-      baseLine = this._workaround(this.baseLanguage, baseLine);
-      result = this.hljs.highlight(
-        this.baseLanguage,
-        baseLine,
-        true,
-        state.baseContext
-      );
-      this.baseRanges.push(this._rangesFromString(result.value, rangesCache));
-      state.baseContext = result.top;
-    }
-
-    if (
-      this.revisionLanguage &&
-      revisionLine !== undefined &&
-      this.hljs.getLanguage(this.revisionLanguage)
-    ) {
-      revisionLine = this._workaround(this.revisionLanguage, revisionLine);
-      result = this.hljs.highlight(
-        this.revisionLanguage,
-        revisionLine,
-        true,
-        state.revisionContext
-      );
-      this.revisionRanges.push(
-        this._rangesFromString(result.value, rangesCache)
-      );
-      state.revisionContext = result.top;
-    }
-  }
-
-  /**
-   * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
-   * cases before sending them into HLJS so that they parse correctly.
-   *
-   * Important notes:
-   * * These tests should be as constrained as possible to avoid interfering
-   * with code it shouldn't AND to avoid executing regexes as much as
-   * possible.
-   * * These tests should document the issue clearly enough that the test can
-   * be confidently removed when the issue is solved in HLJS.
-   * * These tests should rewrite the line of code to have the same number of
-   * characters. This method rewrites the string that gets parsed, but NOT
-   * the string that gets displayed and highlighted. Thus, the positions
-   * must be consistent.
-   *
-   * @param language The name of the HLJS language plugin in use.
-   * @param line The line of code to potentially rewrite.
-   * @return A potentially-rewritten line of code.
-   */
-  _workaround(language: string, line: string) {
-    if (language === 'cpp') {
-      /**
-       * Prevent confusing < and << operators for the start of a meta string
-       * by converting them to a different operator.
-       * {@see Issue 4864}
-       * {@see https://github.com/isagalaev/highlight.js/issues/1341}
-       */
-      if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
-        line = line.replace(GLOBAL_LT_PATTERN, '|');
-      }
-
-      /**
-       * Rewrite CPP wchar_t characters literals to wchar_t string literals
-       * because HLJS only understands the string form.
-       * {@see Issue 5242}
-       * {#see https://github.com/isagalaev/highlight.js/issues/1412}
-       */
-      if (CPP_WCHAR_PATTERN.test(line)) {
-        line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
-      }
-
-      return line;
-    }
-
-    /**
-     * Prevent confusing the closing paren of a parameterized Java annotation
-     * being applied to a formal argument as the closing paren of the argument
-     * list. Rewrite the parens as spaces.
-     * {@see Issue 4776}
-     * {@see https://github.com/isagalaev/highlight.js/issues/1324}
-     */
-    if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
-      return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
-    }
-
-    /**
-     * HLJS misunderstands backslash character literals in Go.
-     * {@see Issue 5007}
-     * {#see https://github.com/isagalaev/highlight.js/issues/1411}
-     */
-    if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
-      return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
-    }
-
-    return line;
-  }
-
-  /**
-   * Tells whether the state has exhausted its current section.
-   */
-  _isSectionDone(state: SyntaxLayerState) {
-    if (!this.diff) return true;
-    const section = this.diff.content[state.sectionIndex];
-    if (section.ab) {
-      return state.lineIndex >= section.ab.length;
-    } else {
-      return (
-        (!section.a || state.lineIndex >= section.a.length) &&
-        (!section.b || state.lineIndex >= section.b.length)
-      );
-    }
-  }
-
-  /**
-   * For a given state, notify layer listeners of any processed line ranges
-   * that have not yet been notified.
-   */
-  _notify(state: SyntaxLayerState) {
-    if (state.lineNums.left - state.lastNotify.left) {
-      this._notifyRange(
-        state.lastNotify.left,
-        // We have to notify 1-based inclusive, so subtract 1.
-        state.lineNums.left - 1,
-        Side.LEFT
-      );
-      state.lastNotify.left = state.lineNums.left;
-    }
-    if (state.lineNums.right - state.lastNotify.right) {
-      this._notifyRange(
-        state.lastNotify.right,
-        // We have to notify 1-based inclusive, so subtract 1.
-        state.lineNums.right - 1,
-        Side.RIGHT
-      );
-      state.lastNotify.right = state.lineNums.right;
-    }
-  }
-
-  _notifyRange(start: number, end: number, side: Side) {
-    for (const listener of this.listeners) {
-      listener(start, end, side);
-    }
-  }
-
-  _loadHLJS() {
-    return this.libLoader.getLibrary(HLJS_LIBRARY_CONFIG).then(hljs => {
-      this.hljs = hljs as HighlightJS;
-    });
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer_test.js
deleted file mode 100644
index 2b03bd3..0000000
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer_test.js
+++ /dev/null
@@ -1,479 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-syntax-layer.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrSyntaxLayer} from './gr-syntax-layer.js';
-
-suite('gr-syntax-layer tests', () => {
-  let diff;
-  let element;
-  const lineNumberEl = document.createElement('td');
-
-  function getMockHLJS() {
-    const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
-        'ipsum</span>';
-    return {
-      configure() {},
-      highlight(lang, line, ignore, state) {
-        return {
-          value: line.replace(/ipsum/, html),
-          top: state === undefined ? 1 : state + 1,
-        };
-      },
-      // Return something truthy because this method is used to check if the
-      // language is supported.
-      getLanguage(s) {
-        return {};
-      },
-    };
-  }
-
-  setup(() => {
-    element = new GrSyntaxLayer();
-    diff = getMockDiffResponse();
-    element.diff = diff;
-  });
-
-  teardown(() => {
-    if (window.hljs) {
-      delete window.hljs;
-    }
-  });
-
-  test('annotate without range does nothing', () => {
-    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = 'Etiam dui, blandit wisi.';
-    const line = new GrDiffLine(GrDiffLineType.REMOVE);
-    line.beforeNumber = 12;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  test('annotate with range applies it', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = str;
-    const line = new GrDiffLine(GrDiffLineType.REMOVE);
-    line.beforeNumber = 12;
-    element.baseRanges[11] = [{
-      start,
-      length,
-      className,
-    }];
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isTrue(annotationSpy.called);
-    assert.equal(annotationSpy.lastCall.args[0], el);
-    assert.equal(annotationSpy.lastCall.args[1], start);
-    assert.equal(annotationSpy.lastCall.args[2], length);
-    assert.equal(annotationSpy.lastCall.args[3], className);
-    assert.isOk(el.querySelector('hl.' + className));
-  });
-
-  test('annotate with range but disabled does nothing', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = str;
-    const line = new GrDiffLine(GrDiffLineType.REMOVE);
-    line.beforeNumber = 12;
-    element.baseRanges[11] = [{
-      start,
-      length,
-      className,
-    }];
-    element.enabled = false;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  test('process on empty diff does nothing', async () => {
-    element.diff = {
-      meta_a: {content_type: 'application/json'},
-      meta_b: {content_type: 'application/json'},
-      content: [],
-    };
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-
-    await element.process();
-
-    assert.isFalse(processNextSpy.called);
-    assert.equal(element.baseRanges.length, 0);
-    assert.equal(element.revisionRanges.length, 0);
-  });
-
-  test('process for unsupported languages does nothing', async () => {
-    element.diff = {
-      meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
-      meta_b: {content_type: 'application/not-a-real-language'},
-      content: [],
-    };
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-
-    await element.process();
-
-    assert.isFalse(processNextSpy.called);
-    assert.equal(element.baseRanges.length, 0);
-    assert.equal(element.revisionRanges.length, 0);
-  });
-
-  test('process while disabled does nothing', async () => {
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-    element.enabled = false;
-    const loadHLJSSpy = sinon.spy(element, '_loadHLJS');
-
-    await element.process();
-
-    assert.isFalse(processNextSpy.called);
-    assert.equal(element.baseRanges.length, 0);
-    assert.equal(element.revisionRanges.length, 0);
-    assert.isFalse(loadHLJSSpy.called);
-  });
-
-  test('process highlight ipsum', async () => {
-    element.diff.meta_a.content_type = 'application/json';
-    element.diff.meta_b.content_type = 'application/json';
-
-    const mockHLJS = getMockHLJS();
-    window.hljs = mockHLJS;
-    const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-    const notifyRangeSpy = sinon.spy(element, '_notifyRange');
-    await element.process();
-
-    const linesA = diff.meta_a.lines;
-    const linesB = diff.meta_b.lines;
-
-    assert.isTrue(processNextSpy.called);
-    assert.equal(element.baseRanges.length, linesA);
-    assert.equal(element.revisionRanges.length, linesB);
-
-    assert.equal(highlightSpy.callCount, linesA + linesB);
-
-    assert.isTrue(notifyRangeSpy.called);
-    assert.equal(notifyRangeSpy.lastCall.args[0], 44);
-    assert.equal(notifyRangeSpy.lastCall.args[1], 48);
-    assert.equal(notifyRangeSpy.lastCall.args[2], 'right');
-
-    // The first line of both sides have a range.
-    let ranges = [element.baseRanges[0], element.revisionRanges[0]];
-    for (const range of ranges) {
-      assert.equal(range.length, 1);
-      assert.equal(range[0].className,
-          'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 'lorem '.length);
-      assert.equal(range[0].length, 'ipsum'.length);
-    }
-
-    // There are no ranges from ll.1-12 on the left and ll.1-11 on the
-    // right.
-    ranges = element.baseRanges.slice(1, 12)
-        .concat(element.revisionRanges.slice(1, 11));
-
-    for (const range of ranges) {
-      assert.equal(range.length, 0);
-    }
-
-    // There should be another pair of ranges on l.13 for the left and
-    // l.12 for the right.
-    ranges = [element.baseRanges[13], element.revisionRanges[12]];
-
-    for (const range of ranges) {
-      assert.equal(range.length, 1);
-      assert.equal(range[0].className,
-          'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 32);
-      assert.equal(range[0].length, 'ipsum'.length);
-    }
-
-    // The next group should have a similar instance on either side.
-
-    let range = element.baseRanges[15];
-    assert.equal(range.length, 1);
-    assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-    assert.equal(range[0].start, 34);
-    assert.equal(range[0].length, 'ipsum'.length);
-
-    range = element.revisionRanges[14];
-    assert.equal(range.length, 1);
-    assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-    assert.equal(range[0].start, 35);
-    assert.equal(range[0].length, 'ipsum'.length);
-  });
-
-  test('init calls cancel', () => {
-    const cancelSpy = sinon.spy(element, 'cancel');
-    element.init({content: []});
-    assert.isTrue(cancelSpy.called);
-  });
-
-  test('_rangesFromElement no ranges', () => {
-    const elem = document.createElement('span');
-    elem.textContent = 'Etiam dui, blandit wisi.';
-    const offset = 100;
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 0);
-  });
-
-  test('_rangesFromElement single range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui, blandit';
-    const str2 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 1);
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length);
-    assert.equal(result[0].className, className);
-  });
-
-  test('_rangesFromElement non-allowed', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui, blandit';
-    const str2 = ' wisi.';
-    const className = 'not-in-the-safelist';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 0);
-  });
-
-  test('_rangesFromElement milti range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui,';
-    const str2 = ' blandit';
-    const str3 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    let span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-    span = document.createElement('span');
-    span.textContent = str3;
-    span.className = className;
-    elem.appendChild(span);
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 2);
-
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length);
-    assert.equal(result[0].className, className);
-
-    assert.equal(result[1].start,
-        str0.length + str1.length + str2.length + offset);
-    assert.equal(result[1].length, str3.length);
-    assert.equal(result[1].className, className);
-  });
-
-  test('_rangesFromElement nested range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui,';
-    const str2 = ' blandit';
-    const str3 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span1 = document.createElement('span');
-    span1.textContent = str1;
-    span1.className = className;
-    elem.appendChild(span1);
-    const span2 = document.createElement('span');
-    span2.textContent = str2;
-    span2.className = className;
-    span1.appendChild(span2);
-    elem.appendChild(document.createTextNode(str3));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 2);
-
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length + str2.length);
-    assert.equal(result[0].className, className);
-
-    assert.equal(result[1].start, str0.length + str1.length + offset);
-    assert.equal(result[1].length, str2.length);
-    assert.equal(result[1].className, className);
-  });
-
-  test('_rangesFromString safelist allows recursion', () => {
-    const str = [
-      '<span class="non-whtelisted-class">',
-      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
-      '</span>'].join('');
-    const result = element._rangesFromString(str, new Map());
-    assert.notEqual(result.length, 0);
-  });
-
-  test('_rangesFromString cache same syntax markers', () => {
-    sinon.spy(element, '_rangesFromElement');
-    const str =
-      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
-    const cacheMap = new Map();
-    element._rangesFromString(str, cacheMap);
-    element._rangesFromString(str, cacheMap);
-    assert.isTrue(element._rangesFromElement.calledOnce);
-  });
-
-  test('_isSectionDone', () => {
-    let state = {sectionIndex: 0, lineIndex: 0};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 0, lineIndex: 2};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 0, lineIndex: 4};
-    assert.isTrue(element._isSectionDone(state));
-
-    state = {sectionIndex: 1, lineIndex: 2};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 1, lineIndex: 3};
-    assert.isTrue(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 0};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 3};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 4};
-    assert.isTrue(element._isSectionDone(state));
-  });
-
-  test('workaround CPP LT directive', () => {
-    // Does nothing to regular line.
-    let line = 'int main(int argc, char** argv) { return 0; }';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Does nothing to include directive.
-    line = '#include <stdio>';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Converts left-shift operator in #define.
-    line = '#define GiB (1ull << 30)';
-    let expected = '#define GiB (1ull || 30)';
-    assert.equal(element._workaround('cpp', line), expected);
-
-    // Converts less-than operator in #if.
-    line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
-    expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
-    assert.equal(element._workaround('cpp', line), expected);
-  });
-
-  test('workaround Java param-annotation', () => {
-    // Does nothing to regular line.
-    let line = 'public static void foo(int bar) { }';
-    assert.equal(element._workaround('java', line), line);
-
-    // Does nothing to regular annotation.
-    line = 'public static void foo(@Nullable int bar) { }';
-    assert.equal(element._workaround('java', line), line);
-
-    // Converts parameterized annotation.
-    line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
-    const expected = 'public static void foo(@SuppressWarnings "unused" ' +
-        ' int bar) { }';
-    assert.equal(element._workaround('java', line), expected);
-  });
-
-  test('workaround CPP whcar_t character literals', () => {
-    // Does nothing to regular line.
-    let line = 'int main(int argc, char** argv) { return 0; }';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Does nothing to wchar_t string.
-    line = 'wchar_t* sz = L"abc 123";';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Converts wchar_t character literal to string.
-    line = 'wchar_t myChar = L\'#\'';
-    let expected = 'wchar_t myChar = L"."';
-    assert.equal(element._workaround('cpp', line), expected);
-
-    // Converts wchar_t character literal with escape sequence to string.
-    line = 'wchar_t myChar = L\'\\"\'';
-    expected = 'wchar_t myChar = L"\\."';
-    assert.equal(element._workaround('cpp', line), expected);
-  });
-
-  test('workaround go backslash character literals', () => {
-    // Does nothing to regular line.
-    let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
-    assert.equal(element._workaround('go', line), line);
-
-    // Does nothing to string with backslash literal
-    line = 'c := "\\\\"';
-    assert.equal(element._workaround('go', line), line);
-
-    // Converts backslash literal character to a string.
-    line = 'c := \'\\\\\'';
-    const expected = 'c := "\\\\"';
-    assert.equal(element._workaround('go', line), expected);
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
index 267aaaf..363de13 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -30,15 +30,46 @@
   .contentText {
     color: var(--syntax-default-color);
   }
+  .gr-syntax-attr {
+    color: var(--syntax-attr-color);
+  }
   .gr-syntax-attribute {
     color: var(--syntax-attribute-color);
   }
+  .gr-syntax-built_in {
+    color: var(--syntax-built_in-color);
+  }
+  .gr-syntax-bullet {
+    color: var(--syntax-bullet-color);
+  }
+  .gr-syntax-code {
+    color: var(--syntax-code-color);
+  }
+  .gr-syntax-comment {
+    color: var(--syntax-comment-color);
+  }
+  .gr-syntax-doctag {
+    font-weight: var(--syntax-doctag-weight);
+  }
+  .gr-syntax-formula {
+    color: var(--syntax-formula-color);
+  }
   .gr-syntax-function {
     color: var(--syntax-function-color);
   }
+  .gr-syntax-link {
+    color: var(--syntax-link-color);
+  }
+  .gr-syntax-literal {
+    /* XML/HTML Attribute */
+    color: var(--syntax-literal-color);
+  }
   .gr-syntax-meta {
     color: var(--syntax-meta-color);
   }
+  .gr-syntax-meta-keyword {
+    color: var(--syntax-meta-keyword-color);
+  }
   .gr-syntax-keyword,
   .gr-syntax-name {
     color: var(--syntax-keyword-color);
@@ -46,69 +77,62 @@
   .gr-syntax-number {
     color: var(--syntax-number-color);
   }
-  .gr-syntax-selector-class {
-    color: var(--syntax-selector-class-color);
-  }
-  .gr-syntax-variable {
-    color: var(--syntax-variable-color);
-  }
-  .gr-syntax-template-variable {
-    color: var(--syntax-template-variable-color);
-  }
-  .gr-syntax-comment {
-    color: var(--syntax-comment-color);
-  }
-  .gr-syntax-string {
-    color: var(--syntax-string-color);
-  }
-  .gr-syntax-selector-id {
-    color: var(--syntax-selector-id-color);
-  }
-  .gr-syntax-built_in {
-    color: var(--syntax-built_in-color);
-  }
-  .gr-syntax-tag {
-    color: var(--syntax-tag-color);
-  }
-  .gr-syntax-link {
-    color: var(--syntax-link-color);
-  }
-  .gr-syntax-meta-keyword {
-    color: var(--syntax-meta-keyword-color);
-  }
-  .gr-syntax-type {
-    color: var(--syntax-type-color);
-  }
-  .gr-syntax-title {
-    color: var(--syntax-title-color);
-  }
-  .gr-syntax-attr {
-    color: var(--syntax-attr-color);
-  }
-  .gr-syntax-literal {
-    /* XML/HTML Attribute */
-    color: var(--syntax-literal-color);
+  .gr-syntax-params {
+    color: var(--syntax-params-color);
   }
   .gr-syntax-property {
     color: var(--syntax-property-color);
   }
-  .gr-syntax-selector-pseudo {
-    color: var(--syntax-selector-pseudo-color);
+  .gr-syntax-quote {
+    color: var(--syntax-quote-color);
   }
   .gr-syntax-regexp {
     color: var(--syntax-regexp-color);
   }
+  .gr-syntax-section {
+    color: var(--syntax-section-color);
+  }
   .gr-syntax-selector-attr {
     color: var(--syntax-selector-attr-color);
   }
+  .gr-syntax-selector-class {
+    color: var(--syntax-selector-class-color);
+  }
+  .gr-syntax-selector-id {
+    color: var(--syntax-selector-id-color);
+  }
+  .gr-syntax-selector-pseudo {
+    color: var(--syntax-selector-pseudo-color);
+  }
+  .gr-syntax-string {
+    color: var(--syntax-string-color);
+  }
+  .gr-syntax-strong {
+    color: var(--syntax-strong-color);
+  }
+  .gr-syntax-tag {
+    color: var(--syntax-tag-color);
+  }
   .gr-syntax-template-tag {
     color: var(--syntax-template-tag-color);
   }
-  .gr-syntax-params {
-    color: var(--syntax-params-color);
+  .gr-syntax-template-variable {
+    color: var(--syntax-template-variable-color);
   }
-  .gr-syntax-doctag {
-    font-weight: var(--syntax-doctag-weight);
+  .gr-syntax-title {
+    color: var(--syntax-title-color);
+  }
+  .gr-syntax-title.function_ {
+    color: var(--syntax-title-function-color);
+  }
+  .gr-syntax-type {
+    color: var(--syntax-type-color);
+  }
+  .gr-syntax-variable {
+    color: var(--syntax-variable-color);
+  }
+  .gr-syntax-variable.language_ {
+    color: var(--syntax-variable-language-color);
   }
 `;
 
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index 7d2d742..46bdec8 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -29,7 +29,7 @@
   finalize() {}
 
   /**
-   * @returns array of all enabled experiments.
+   * @return array of all enabled experiments.
    */
   get enabledExperiments() {
     return [];
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
deleted file mode 100644
index bb46484..0000000
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {createDiffAppContext} from './gr-diff-app-context-init.js';
-
-suite('gr diff app context initializer tests', () => {
-  test('all services initialized and are singletons', () => {
-    const appContext = createDiffAppContext();
-    Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
-      assert.isNotNull(service);
-      const service2 = appContext[serviceName];
-      assert.strictEqual(service, service2);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
new file mode 100644
index 0000000..84fd859
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AppContext} from '../services/app-context';
+import '../test/common-test-setup-karma';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+
+suite('gr diff app context initializer tests', () => {
+  test('all services initialized and are singletons', () => {
+    const appContext: AppContext = createDiffAppContext();
+    for (const serviceName of Object.keys(appContext) as Array<
+      keyof AppContext
+    >) {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    }
+  });
+});
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index 1631126..79a3c46 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -23,8 +23,23 @@
 import {hovercardStyles} from '../../styles/gr-hovercard-styles';
 import {sharedStyles} from '../../styles/shared-styles';
 import {DependencyRequestEvent} from '../../models/dependency';
-import {addShortcut, Key} from '../../utils/dom-util';
+import {
+  addShortcut,
+  findActiveElement,
+  isElementTarget,
+  Key,
+  Modifier,
+} from '../../utils/dom-util';
 import {ShortcutController} from '../../elements/lit/shortcut-controller';
+import {
+  getFocusableElements,
+  getFocusableElementsReverse,
+} from '../../utils/focusable';
+import {getAppContext} from '../../services/app-context';
+import {
+  ReportingService,
+  Timer,
+} from '../../services/gr-reporting/gr-reporting';
 
 interface ReloadEventDetail {
   clearPatchset?: boolean;
@@ -137,8 +152,15 @@
 
     openedByKeyboard = false;
 
+    reporting: ReportingService = getAppContext().reportingService;
+
+    reportingTimer?: Timer;
+
     private targetCleanups: Array<() => void> = [];
 
+    /** Called in disconnectedCallback. */
+    private cleanups: (() => void)[] = [];
+
     static get styles() {
       return [sharedStyles, hovercardStyles];
     }
@@ -167,11 +189,37 @@
       }
 
       this.container = getHovercardContainer({createIfNotExists: true});
+      this.cleanups.push(
+        addShortcut(
+          this,
+          {key: Key.TAB},
+          (e: KeyboardEvent) => {
+            this.pressTab(e);
+          },
+          {
+            doNotPrevent: true,
+          }
+        )
+      );
+      this.cleanups.push(
+        addShortcut(
+          this,
+          {key: Key.TAB, modifiers: [Modifier.SHIFT_KEY]},
+          (e: KeyboardEvent) => {
+            this.pressShiftTab(e);
+          },
+          {
+            doNotPrevent: true,
+          }
+        )
+      );
     }
 
     override disconnectedCallback() {
       this.cancelShowTask();
       this.cancelHideTask();
+      for (const cleanup of this.cleanups) cleanup();
+      this.cleanups = [];
       super.disconnectedCallback();
     }
 
@@ -317,6 +365,12 @@
       return target as HTMLElement;
     }
 
+    private readonly documentClickListener = (e: MouseEvent) => {
+      if (!e.target || !isElementTarget(e.target)) return;
+      if (this.contains(e.target)) return;
+      this.forceHide();
+    };
+
     /**
      * Hovercards aren't children of <gr-app>. Dependencies must be resolved via
      * their targets, so re-route 'request-dependency' events.
@@ -380,6 +434,11 @@
       if (this.container?.contains(this)) {
         this.container.removeChild(this);
       }
+      document.removeEventListener('click', this.documentClickListener);
+      this.reportingTimer?.end({
+        targetId: this._target?.id,
+        tagName: this.tagName,
+      });
     };
 
     /**
@@ -407,6 +466,29 @@
       );
     }
 
+    override focus(options?: FocusOptions): void {
+      const a = getFocusableElements(this).next();
+      if (!a.done) a.value.focus(options);
+    }
+
+    pressTab(e: KeyboardEvent) {
+      const activeElement = findActiveElement(document);
+      const lastFocusable = getFocusableElementsReverse(this).next();
+      if (!lastFocusable.done && activeElement === lastFocusable.value) {
+        e.preventDefault();
+        this.forceHide();
+      }
+    }
+
+    pressShiftTab(e: KeyboardEvent) {
+      const activeElement = findActiveElement(document);
+      const firstFocusable = getFocusableElements(this).next();
+      if (!firstFocusable.done && activeElement === firstFocusable.value) {
+        e.preventDefault();
+        this.forceHide();
+      }
+    }
+
     cancelShowTask() {
       if (!this.showTask) return;
       this.showTask.cancel();
@@ -432,7 +514,6 @@
 
       // Mark that the hovercard is now visible
       this._isShowing = true;
-      this.setAttribute('tabindex', '0');
 
       // Add it to the DOM and calculate its position
       this.container.appendChild(this);
@@ -451,6 +532,8 @@
       if (props?.keyboardEvent) {
         this.focus();
       }
+      document.addEventListener('click', this.documentClickListener);
+      this.reportingTimer = this.reporting.getTimer('Show Hovercard');
     };
 
     updatePosition() {
@@ -562,7 +645,7 @@
   _target: HTMLElement | null;
   _isShowing: boolean;
   dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
-  show(props: MouseKeyboardOrFocusEvent): void;
+  show(props: MouseKeyboardOrFocusEvent): Promise<void>;
   forceHide(): void;
 
   // Used for tests
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
index 765ae27..88e57a0 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -38,7 +38,10 @@
   }
 
   override render() {
-    return html`<div id="container"><slot></slot></div>`;
+    return html` <div id="container">
+      <span tabindex="0" id="focusable"></span>
+      <slot></slot>
+    </div>`;
   }
 }
 
@@ -63,7 +66,7 @@
   teardown(() => {
     pressKey(element, Key.ESC);
     element.mouseHide(new MouseEvent('click'));
-    button?.remove();
+    if (button) button.remove();
   });
 
   test('updatePosition', async () => {
@@ -211,7 +214,7 @@
     await element.updateComplete;
     assert.isTrue(element._isShowing);
     const activeElement = findActiveElement(document);
-    assert.isTrue(activeElement === element);
+    assert.equal(activeElement?.id, 'focusable');
   });
 
   test('when on mouseEvent, focus is not moved to hovercard', async () => {
@@ -222,6 +225,6 @@
     await element.updateComplete;
     assert.isTrue(element._isShowing);
     const activeElement = findActiveElement(document);
-    assert.isTrue(activeElement !== element);
+    assert.notEqual(activeElement?.id, 'focusable');
   });
 });
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
index 57e034f..b41b42b 100644
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -38,5 +38,5 @@
 ): T & Constructor<IronFitBehavior> =>
   // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
   // which will fail the type check due to missing IronFitBehavior interface
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  // eslint-disable-next-line
   mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
index 8429e38..c1aa7ef 100644
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
@@ -38,5 +38,5 @@
   // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
   // instead which will fail the type check due to missing
   // IronOverlayBehavior interface
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  // eslint-disable-next-line
   mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index 0b5b67f..a34f880 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -4,16 +4,26 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import {ChangeInfo, NumericChangeId} from '../../api/rest-api';
+import {
+  ChangeInfo,
+  NumericChangeId,
+  ChangeStatus,
+  ReviewerState,
+  AccountId,
+  AccountInfo,
+  GroupInfo,
+} from '../../api/rest-api';
 import {Model} from '../model';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
 import {
-  listChangesOptionsToHex,
-  ListChangesOption,
-} from '../../utils/change-util';
+  ReviewInput,
+  ReviewerInput,
+  AttentionSetInput,
+} from '../../types/common';
+import {accountOrGroupKey} from '../../utils/account-util';
 
 export const bulkActionsModelToken =
   define<BulkActionsModel>('bulk-actions-model');
@@ -26,21 +36,19 @@
 export interface BulkActionsState {
   loadingState: LoadingState;
   selectedChangeNums: NumericChangeId[];
+  allChanges: Map<NumericChangeId, ChangeInfo>;
 }
 
 const initialState: BulkActionsState = {
   loadingState: LoadingState.NOT_SYNCED,
   selectedChangeNums: [],
+  allChanges: new Map(),
 };
 
 export class BulkActionsModel
   extends Model<BulkActionsState>
   implements Finalizable
 {
-  // A map of all the changes in a section that can be bulk-acted upon.
-  // Private but used in tests.
-  allChanges: Map<NumericChangeId, ChangeInfo> = new Map();
-
   constructor(private readonly restApiService: RestApiService) {
     super(initialState);
   }
@@ -50,30 +58,49 @@
     bulkActionsState => bulkActionsState.selectedChangeNums
   );
 
+  public readonly totalChangeCount$ = select(
+    this.state$,
+    bulkActionsState => bulkActionsState.allChanges.size
+  );
+
   public readonly loadingState$ = select(
     this.state$,
     bulkActionsState => bulkActionsState.loadingState
   );
 
+  public readonly allChanges$ = select(
+    this.state$,
+    bulkActionsState => bulkActionsState.allChanges
+  );
+
+  public readonly selectedChanges$ = select(this.state$, bulkActionsState => {
+    const result = [];
+    for (const changeNum of bulkActionsState.selectedChangeNums) {
+      const change = bulkActionsState.allChanges.get(changeNum);
+      if (change) result.push(change);
+    }
+    return result;
+  });
+
   addSelectedChangeNum(changeNum: NumericChangeId) {
-    if (!this.allChanges.has(changeNum)) {
+    const current = this.getState();
+    if (!current.allChanges.has(changeNum)) {
       throw new Error(
         `Trying to add change ${changeNum} that is not part of bulk-actions model`
       );
     }
-    const current = this.subject$.getValue();
     const selectedChangeNums = [...current.selectedChangeNums];
     selectedChangeNums.push(changeNum);
     this.setState({...current, selectedChangeNums});
   }
 
   removeSelectedChangeNum(changeNum: NumericChangeId) {
-    if (!this.allChanges.has(changeNum)) {
+    const current = this.getState();
+    if (!current.allChanges.has(changeNum)) {
       throw new Error(
         `Trying to remove change ${changeNum} that is not part of bulk-actions model`
       );
     }
-    const current = this.subject$.getValue();
     const selectedChangeNums = [...current.selectedChangeNums];
     const index = selectedChangeNums.findIndex(item => item === changeNum);
     if (index === -1) return;
@@ -81,39 +108,134 @@
     this.setState({...current, selectedChangeNums});
   }
 
-  async sync(changes: ChangeInfo[]) {
-    const newChanges = new Map(changes.map(c => [c._number, c]));
-    this.allChanges = newChanges;
+  clearSelectedChangeNums() {
+    this.setState({...this.subject$.getValue(), selectedChangeNums: []});
+  }
+
+  selectAll() {
     const current = this.subject$.getValue();
-    const selectedChangeNums = current.selectedChangeNums.filter(changeNum =>
-      newChanges.has(changeNum)
-    );
     this.setState({
       ...current,
+      selectedChangeNums: Array.from(current.allChanges.keys()),
+    });
+  }
+
+  abandonChanges(
+    reason?: string,
+    // errorFn is needed to avoid showing an error dialog
+    errFn?: (changeNum: NumericChangeId) => void
+  ): Promise<Response | undefined>[] {
+    const current = this.subject$.getValue();
+    return current.selectedChangeNums.map(changeNum => {
+      if (!current.allChanges.get(changeNum))
+        throw new Error('invalid change id');
+      const change = current.allChanges.get(changeNum)!;
+      if (change.status === ChangeStatus.ABANDONED) {
+        return Promise.resolve(new Response());
+      }
+      return this.restApiService.executeChangeAction(
+        change._number,
+        change.actions!.abandon!.method,
+        '/abandon',
+        undefined,
+        {message: reason ?? ''},
+        () => errFn && errFn(change._number)
+      );
+    });
+  }
+
+  voteChanges(reviewInput: ReviewInput) {
+    const current = this.subject$.getValue();
+    return current.selectedChangeNums.map(changeNum => {
+      const change = current.allChanges.get(changeNum)!;
+      if (!change) throw new Error('invalid change id');
+      return this.restApiService.saveChangeReview(
+        change._number,
+        'current',
+        reviewInput,
+        () => {
+          throw new Error();
+        }
+      );
+    });
+  }
+
+  addReviewers(
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
+    reason: string
+  ): Promise<Response>[] {
+    const current = this.subject$.getValue();
+    const changes = current.selectedChangeNums.map(
+      changeNum => current.allChanges.get(changeNum)!
+    );
+    return changes.map(change => {
+      const reviewersNewToChange: ReviewerInput[] = [
+        ReviewerState.REVIEWER,
+        ReviewerState.CC,
+      ].flatMap(state =>
+        this.getNewReviewersToChange(change, state, changedReviewers)
+      );
+      if (reviewersNewToChange.length === 0) {
+        return Promise.resolve(new Response());
+      }
+      const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
+        .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
+        .map(reviewerInput => {
+          return {
+            // TODO: Once Groups are supported, filter them out and only add
+            // Accounts to the attention set, just like gr-reply-dialog.
+            user: reviewerInput.reviewer as AccountId,
+            reason,
+          };
+        });
+      const reviewInput: ReviewInput = {
+        reviewers: reviewersNewToChange,
+        ignore_automatic_attention_set_rules: true,
+        add_to_attention_set: attentionSetUpdates,
+      };
+      return this.restApiService.saveChangeReview(
+        change._number,
+        'current',
+        reviewInput
+      );
+    });
+  }
+
+  async sync(changes: ChangeInfo[]) {
+    const basicChanges = new Map(changes.map(c => [c._number, c]));
+    let currentState = this.subject$.getValue();
+    const selectedChangeNums = currentState.selectedChangeNums.filter(
+      changeNum => basicChanges.has(changeNum)
+    );
+    this.setState({
+      ...currentState,
       loadingState: LoadingState.LOADING,
       selectedChangeNums,
+      allChanges: basicChanges,
     });
 
-    const query = changes.map(c => `change:${c._number}`).join(' OR ');
-    const changeDetails = await this.restApiService.getChanges(
-      undefined,
-      query,
-      undefined,
-      listChangesOptionsToHex(
-        ListChangesOption.CHANGE_ACTIONS,
-        ListChangesOption.CURRENT_ACTIONS,
-        ListChangesOption.CURRENT_REVISION,
-        ListChangesOption.DETAILED_LABELS
-      )
-    );
+    if (changes.length === 0) {
+      return;
+    }
+    const changeDetails =
+      await this.restApiService.getDetailedChangesWithActions(
+        changes.map(c => c._number)
+      );
+    currentState = this.subject$.getValue();
     // Return early if sync has been called again since starting the load.
-    if (newChanges !== this.allChanges) return;
-    for (const change of changeDetails ?? []) {
-      this.allChanges.set(change._number, change);
+    if (basicChanges !== currentState.allChanges) return;
+    const allDetailedChanges: Map<NumericChangeId, ChangeInfo> = new Map();
+    for (const detailedChange of changeDetails ?? []) {
+      const basicChange = basicChanges.get(detailedChange._number)!;
+      allDetailedChanges.set(
+        detailedChange._number,
+        this.mergeOldAndDetailedChangeInfos(basicChange, detailedChange)
+      );
     }
     this.setState({
-      ...this.subject$.getValue(),
+      ...currentState,
       loadingState: LoadingState.LOADED,
+      allChanges: allDetailedChanges,
     });
   }
 
@@ -126,5 +248,31 @@
     this.subject$.next(state);
   }
 
+  private mergeOldAndDetailedChangeInfos(
+    originalChange: ChangeInfo,
+    newData: ChangeInfo
+  ) {
+    return {
+      ...originalChange,
+      ...newData,
+      reviewers: originalChange.reviewers,
+    };
+  }
+
+  private getNewReviewersToChange(
+    change: ChangeInfo,
+    state: ReviewerState,
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>
+  ): ReviewerInput[] {
+    return (
+      changedReviewers
+        .get(state)
+        ?.filter(account => !change.reviewers[state]?.includes(account))
+        .map(account => {
+          return {state, reviewer: accountOrGroupKey(account)};
+        }) ?? []
+    );
+  }
+
   finalize() {}
 }
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index d1802d2..84d5c4e 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -4,24 +4,31 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import {createChange} from '../../test/test-data-generators';
-import {ChangeInfo, NumericChangeId} from '../../api/rest-api';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+  createGroupInfo,
+  createRevisions,
+} from '../../test/test-data-generators';
+import {
+  ChangeInfo,
+  NumericChangeId,
+  ChangeStatus,
+  HttpMethod,
+  SubmitRequirementStatus,
+  AccountInfo,
+  ReviewerState,
+  AccountId,
+  GroupInfo,
+} from '../../api/rest-api';
 import {BulkActionsModel, LoadingState} from './bulk-actions-model';
 import {getAppContext} from '../../services/app-context';
 import '../../test/common-test-setup-karma';
 import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
-import {mockPromise, MockPromise} from '../../test/test-utils';
-import {
-  listChangesOptionsToHex,
-  ListChangesOption,
-} from '../../utils/change-util';
-
-const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
-  ListChangesOption.CHANGE_ACTIONS,
-  ListChangesOption.CURRENT_ACTIONS,
-  ListChangesOption.CURRENT_REVISION,
-  ListChangesOption.DETAILED_LABELS
-);
+import {mockPromise} from '../../test/test-utils';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {ReviewInput} from '../../types/common';
 
 suite('bulk actions model test', () => {
   let bulkActionsModel: BulkActionsModel;
@@ -29,6 +36,14 @@
     bulkActionsModel = new BulkActionsModel(getAppContext().restApiService);
   });
 
+  test('does not request detailed changes when no changes are synced', () => {
+    const detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+
+    bulkActionsModel.sync([]);
+
+    assert.isTrue(detailedActionsStub.notCalled);
+  });
+
   test('add changes before sync', () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;
@@ -39,6 +54,7 @@
 
     assert.throws(() => bulkActionsModel.addSelectedChangeNum(c1._number));
     assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+
     bulkActionsModel.sync([c1, c2]);
 
     bulkActionsModel.addSelectedChangeNum(c2._number);
@@ -79,6 +95,251 @@
     assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
   });
 
+  test('clears selected change numbers', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+    bulkActionsModel.addSelectedChangeNum(c1._number);
+    bulkActionsModel.addSelectedChangeNum(c2._number);
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel!.selectedChangeNums$,
+      s => s.length === 2
+    );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+    assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
+
+    bulkActionsModel.clearSelectedChangeNums();
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel!.selectedChangeNums$,
+      s => s.length === 0
+    );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+
+    assert.isEmpty(selectedChangeNums);
+    assert.equal(totalChangeCount, 2);
+  });
+
+  test('selects all changes', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel!.selectedChangeNums$,
+      s => s.length === 0
+    );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+    assert.isEmpty(selectedChangeNums);
+    assert.equal(totalChangeCount, 2);
+
+    bulkActionsModel.selectAll();
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel!.selectedChangeNums$,
+      s => s.length === 2
+    );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+
+    assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
+  });
+
+  suite('abandon changes', () => {
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      const c1 = createChange();
+      c1._number = 1 as NumericChangeId;
+      const c2 = createChange();
+      c2._number = 2 as NumericChangeId;
+
+      detailedActionsStub.returns(
+        Promise.resolve([
+          {...c1, actions: {abandon: {method: HttpMethod.POST}}},
+          {...c2, status: ChangeStatus.ABANDONED},
+        ])
+      );
+
+      bulkActionsModel.sync([c1, c2]);
+
+      bulkActionsModel.addSelectedChangeNum(c1._number);
+      bulkActionsModel.addSelectedChangeNum(c2._number);
+    });
+
+    test('already abandoned change does not call executeChangeAction', () => {
+      const actionStub = stubRestApi('executeChangeAction');
+      bulkActionsModel.abandonChanges();
+      assert.equal(actionStub.callCount, 1);
+      assert.deepEqual(actionStub.lastCall.args.slice(0, 5), [
+        1 as NumericChangeId,
+        HttpMethod.POST,
+        '/abandon',
+        undefined,
+        {message: ''},
+      ]);
+    });
+  });
+
+  suite('add reviewers', () => {
+    const accounts: AccountInfo[] = [
+      createAccountWithIdNameAndEmail(0),
+      createAccountWithIdNameAndEmail(1),
+    ];
+    const groups: GroupInfo[] = [createGroupInfo('groupId')];
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        reviewers: {
+          REVIEWER: [accounts[0]],
+          CC: [accounts[1]],
+        },
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let saveChangeReviewStub: sinon.SinonStub;
+
+    setup(async () => {
+      saveChangeReviewStub = stubRestApi('saveChangeReview').resolves(
+        new Response()
+      );
+      stubRestApi('getDetailedChangesWithActions').resolves([
+        {...changes[0], actions: {abandon: {method: HttpMethod.POST}}},
+        {...changes[1], status: ChangeStatus.ABANDONED},
+      ]);
+      bulkActionsModel.sync(changes);
+      bulkActionsModel.addSelectedChangeNum(changes[0]._number);
+      bulkActionsModel.addSelectedChangeNum(changes[1]._number);
+    });
+
+    test('adds reviewers/cc only to changes that need it', async () => {
+      bulkActionsModel.addReviewers(
+        new Map([
+          [ReviewerState.REVIEWER, [accounts[0], groups[0]]],
+          [ReviewerState.CC, [accounts[1]]],
+        ]),
+        '<GERRIT_ACCOUNT_12345> replied on the change'
+      );
+
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      // changes[0] only adds the group since it already has the other
+      // reviewer/CCs
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [{reviewer: groups[0].id, state: ReviewerState.REVIEWER}],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
+        changes[1]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[1]._account_id, state: ReviewerState.CC},
+          ],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: accounts[0]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+    });
+  });
+
+  suite('voteChanges', () => {
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      const c1 = {...createChange(), revisions: createRevisions(10)};
+      c1._number = 1 as NumericChangeId;
+      const c2 = {...createChange(), revisions: createRevisions(4)};
+      c2._number = 2 as NumericChangeId;
+
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      detailedActionsStub.returns(
+        Promise.resolve([
+          {...c1, actions: {abandon: {method: HttpMethod.POST}}},
+          {...c2, status: ChangeStatus.ABANDONED},
+        ])
+      );
+
+      await bulkActionsModel.sync([c1, c2]);
+
+      bulkActionsModel.addSelectedChangeNum(c1._number);
+      bulkActionsModel.addSelectedChangeNum(c2._number);
+    });
+
+    test('vote changes', () => {
+      const reviewStub = stubRestApi('saveChangeReview');
+      const reviewInput: ReviewInput = {
+        labels: {
+          a: 1,
+        },
+      };
+      bulkActionsModel.voteChanges(reviewInput);
+      assert.equal(reviewStub.callCount, 2);
+      assert.deepEqual(reviewStub.firstCall.args.slice(0, 3), [
+        1 as NumericChangeId,
+        'current',
+        {
+          labels: {
+            a: 1,
+          },
+        },
+      ]);
+
+      assert.deepEqual(reviewStub.secondCall.args.slice(0, 3), [
+        2 as NumericChangeId,
+        'current',
+        {
+          labels: {
+            a: 1,
+          },
+        },
+      ]);
+    });
+  });
+
   test('stale changes are removed from the model', async () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;
@@ -93,15 +354,26 @@
       bulkActionsModel!.selectedChangeNums$,
       s => s.length === 2
     );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
 
     assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
 
     bulkActionsModel.sync([c1]);
     selectedChangeNums = await waitUntilObserved(
       bulkActionsModel!.selectedChangeNums$,
       s => s.length === 1
     );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 1
+    );
+
     assert.sameMembers(selectedChangeNums, [c1._number]);
+    assert.equal(totalChangeCount, 1);
   });
 
   test('sync fetches new changes', async () => {
@@ -115,40 +387,80 @@
       LoadingState.NOT_SYNCED
     );
 
-    const responsePromise: MockPromise<ChangeInfo[]> = mockPromise();
-    const getChangesStub = stubRestApi('getChanges').callsFake(
-      (changesPerPage, query, offset, options) => {
-        assert.isUndefined(changesPerPage);
-        assert.strictEqual(query, 'change:1 OR change:2');
-        assert.isUndefined(offset);
-        assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
-        return responsePromise;
-      }
-    );
     bulkActionsModel.sync([c1, c2]);
-    assert.isTrue(getChangesStub.calledOnce);
     await waitUntilObserved(
       bulkActionsModel.loadingState$,
       s => s === LoadingState.LOADING
     );
 
-    responsePromise.resolve([
-      {...createChange(), _number: 1, subject: 'Subject 1'},
-      {...createChange(), _number: 2, subject: 'Subject 2'},
-    ] as ChangeInfo[]);
+    await waitUntilObserved(
+      bulkActionsModel.loadingState$,
+      s => s === LoadingState.LOADED
+    );
+    const model = bulkActionsModel.getState();
+
+    assert.strictEqual(
+      model.allChanges.get(1 as NumericChangeId)?.subject,
+      'Subject 1'
+    );
+    assert.strictEqual(
+      model.allChanges.get(2 as NumericChangeId)?.subject,
+      'Subject 2'
+    );
+  });
+
+  test('sync retains keys from original change including reviewers', async () => {
+    const c1: ChangeInfo = {
+      ...createChange(),
+      _number: 1 as NumericChangeId,
+      submit_requirements: [
+        {
+          name: 'a',
+          status: SubmitRequirementStatus.FORCED,
+          submittability_expression_result: {
+            expression: 'b',
+          },
+        },
+      ],
+      reviewers: {
+        REVIEWER: [{_account_id: 1 as AccountId, display_name: 'MyName'}],
+      },
+    };
+
+    stubRestApi('getDetailedChangesWithActions').callsFake(() => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        actions: {abandon: {}},
+        // detailed data will be missing names
+        reviewers: {REVIEWER: [createAccountWithIdNameAndEmail()]},
+      };
+      assert.isNotOk(change.submit_requirements);
+      return Promise.resolve([change]);
+    });
+
+    bulkActionsModel.sync([c1]);
+
     await waitUntilObserved(
       bulkActionsModel.loadingState$,
       s => s === LoadingState.LOADED
     );
 
-    assert.strictEqual(
-      bulkActionsModel.allChanges.get(1 as NumericChangeId)?.subject,
-      'Subject 1'
-    );
-    assert.strictEqual(
-      bulkActionsModel.allChanges.get(2 as NumericChangeId)?.subject,
-      'Subject 2'
-    );
+    const changeAfterSync = bulkActionsModel
+      .getState()
+      .allChanges.get(1 as NumericChangeId);
+    assert.deepEqual(changeAfterSync!.submit_requirements, [
+      {
+        name: 'a',
+        status: SubmitRequirementStatus.FORCED,
+        submittability_expression_result: {
+          expression: 'b',
+        },
+      },
+    ]);
+    assert.deepEqual(changeAfterSync!.actions, {abandon: {}});
+    // original reviewers are kept, which includes more details than loaded ones
+    assert.deepEqual(changeAfterSync!.reviewers, c1.reviewers);
   });
 
   test('sync ignores outdated fetch responses', async () => {
@@ -159,15 +471,9 @@
 
     const responsePromise1 = mockPromise<ChangeInfo[]>();
     let promise = responsePromise1;
-    const getChangesStub = stubRestApi('getChanges').callsFake(
-      (changesPerPage, query, offset, options) => {
-        assert.isUndefined(changesPerPage);
-        assert.strictEqual(query, 'change:1 OR change:2');
-        assert.isUndefined(offset);
-        assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
-        return promise;
-      }
-    );
+    const getChangesStub = stubRestApi(
+      'getDetailedChangesWithActions'
+    ).callsFake(() => promise);
     bulkActionsModel.sync([c1, c2]);
     assert.strictEqual(getChangesStub.callCount, 1);
     await waitUntilObserved(
@@ -189,12 +495,13 @@
       bulkActionsModel.loadingState$,
       s => s === LoadingState.LOADED
     );
+    const model = bulkActionsModel.getState();
     assert.strictEqual(
-      bulkActionsModel.allChanges.get(1 as NumericChangeId)?.subject,
+      model.allChanges.get(1 as NumericChangeId)?.subject,
       'Subject 1'
     );
     assert.strictEqual(
-      bulkActionsModel.allChanges.get(2 as NumericChangeId)?.subject,
+      model.allChanges.get(2 as NumericChangeId)?.subject,
       'Subject 2'
     );
 
@@ -204,14 +511,15 @@
       {...createChange(), _number: 2, subject: 'Subject 2-old'},
     ] as ChangeInfo[]);
     await flush();
+    const model2 = bulkActionsModel.getState();
 
     // No change should happen.
     assert.strictEqual(
-      bulkActionsModel.allChanges.get(1 as NumericChangeId)?.subject,
+      model2.allChanges.get(1 as NumericChangeId)?.subject,
       'Subject 1'
     );
     assert.strictEqual(
-      bulkActionsModel.allChanges.get(2 as NumericChangeId)?.subject,
+      model2.allChanges.get(2 as NumericChangeId)?.subject,
       'Subject 2'
     );
   });
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 6a93f33..b9a768c 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -244,7 +244,7 @@
               this.updateStateReviewedFiles([]);
               return of(undefined);
             }
-            return from(this.fetchReviewedFiles(currentPatchNum!, changeNum!));
+            return from(this.fetchReviewedFiles(currentPatchNum, changeNum));
           })
         )
         .subscribe(),
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index 03b71a4..769d7af 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -25,6 +25,7 @@
   UrlEncodedCommentId,
   PathToCommentsInfoMap,
   RobotCommentInfo,
+  PathToRobotCommentsInfoMap,
 } from '../../types/common';
 import {
   addPath,
@@ -50,6 +51,7 @@
 import {pluralize} from '../../utils/string-util';
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Model} from '../model';
+import {Deduping} from '../../api/reporting';
 
 export interface CommentState {
   /** undefined means 'still loading' */
@@ -330,7 +332,7 @@
   }
 
   finalize() {
-    document.removeEventListener('reload', this.reloadListener!);
+    document.removeEventListener('reload', this.reloadListener);
     for (const s of this.subscriptions) {
       s.unsubscribe();
     }
@@ -376,9 +378,38 @@
     const robotComments = await this.restApiService.getDiffRobotComments(
       changeNum
     );
+    this.reportRobotCommentStats(robotComments);
     this.updateState(s => setRobotComments(s, robotComments));
   }
 
+  private reportRobotCommentStats(obj?: PathToRobotCommentsInfoMap) {
+    if (!obj) return;
+    const comments = Object.values(obj).flat();
+    if (comments.length === 0) return;
+    const ids = comments.map(c => c.robot_id);
+    const latestPatchset = comments.reduce(
+      (latestPs, comment) =>
+        Math.max(latestPs, (comment?.patch_set as number) ?? 0),
+      0
+    );
+    const commentsLatest = comments.filter(c => c.patch_set === latestPatchset);
+    const commentsFixes = comments
+      .map(c => c.fix_suggestions?.length ?? 0)
+      .filter(l => l > 0);
+    const details = {
+      firstId: ids[0],
+      ids: [...new Set(ids)],
+      count: comments.length,
+      countLatest: commentsLatest.length,
+      countFixes: commentsFixes.length,
+    };
+    this.reporting.reportInteraction(
+      Interaction.ROBOT_COMMENTS_STATS,
+      details,
+      {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
+    );
+  }
+
   async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
     const drafts = await this.restApiService.getDiffDrafts(changeNum);
     this.updateState(s => setDrafts(s, drafts));
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 9674794..01e2f6b 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -17,7 +17,6 @@
 import '../../test/common-test-setup-karma';
 import {createDraft} from '../../test/test-data-generators';
 import {UrlEncodedCommentId} from '../../types/common';
-import {DraftInfo} from '../../utils/comment-util';
 import './comments-model';
 import {CommentsModel} from './comments-model';
 import {deleteDraft} from './comments-model';
@@ -42,7 +41,7 @@
       comments: {},
       robotComments: {},
       drafts: {
-        [draft.path!]: [draft as DraftInfo],
+        [draft.path!]: [draft],
       },
       portedComments: {},
       portedDrafts: {},
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index e7ac242c..9f7398d 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -102,7 +102,7 @@
  * Type Safety
  * ---
  *
- * Dependency injection is guaranteed npmtype-safe by construction due to the
+ * Dependency injection is guaranteed type-safe by construction due to the
  * typing of the token used to tie together dependency providers and dependency
  * consumers.
  *
@@ -133,16 +133,38 @@
  */
 export type Provider<T> = () => T;
 
+// Symbols to cache the providers and resolvers to avoid duplicate registration.
+const PROVIDERS_SYMBOL = Symbol('providers');
+const RESOLVERS_SYMBOL = Symbol('resolvers');
+
+interface Registrations {
+  [PROVIDERS_SYMBOL]?: Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >;
+  [RESOLVERS_SYMBOL]?: Map<DependencyToken<unknown>, Provider<unknown>>;
+}
 /**
  * A producer of a dependency expresses this as a need that results in a promise
  * for the given dependency.
  */
 export function provide<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>,
   provider: Provider<T>
 ) {
-  host.addController(new DependencyProvider<T>(host, dependency, provider));
+  const hostProviders = (host[PROVIDERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >());
+  const oldController = hostProviders.get(dependency);
+  if (oldController) {
+    host.removeController(oldController);
+    oldController.hostDisconnected();
+  }
+  const controller = new DependencyProvider<T>(host, dependency, provider);
+  hostProviders.set(dependency, controller);
+  host.addController(controller);
 }
 
 /**
@@ -151,12 +173,21 @@
  * the injected value.
  */
 export function resolve<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>
 ): Provider<T> {
-  const controller = new DependencySubscriber(host, dependency);
-  host.addController(controller);
-  return () => controller.get();
+  const hostResolvers = (host[RESOLVERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    Provider<unknown>
+  >());
+  let resolver = hostResolvers.get(dependency);
+  if (!resolver) {
+    const controller = new DependencySubscriber(host, dependency);
+    host.addController(controller);
+    resolver = () => controller.get();
+    hostResolvers.set(dependency, resolver);
+  }
+  return resolver as Provider<T>;
 }
 
 /**
@@ -249,7 +280,7 @@
 }
 
 /**
- * A resolved dependency is valid within the econnectd lifetime of a component,
+ * A resolved dependency is valid within the connected lifetime of a component,
  * namely between connectedCallback and disconnectedCallback.
  */
 interface ResolvedDependency<T> {
diff --git a/polygerrit-ui/app/models/di-provider-element_test.ts b/polygerrit-ui/app/models/di-provider-element_test.ts
index 83feac7..36d73e5 100644
--- a/polygerrit-ui/app/models/di-provider-element_test.ts
+++ b/polygerrit-ui/app/models/di-provider-element_test.ts
@@ -26,9 +26,13 @@
   @state()
   private injectedValue = '';
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getModel(), value => (this.injectedValue = value));
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getModel(),
+      value => (this.injectedValue = value)
+    );
   }
 
   override render() {
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index 5e9ed3f..fb18928 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -23,11 +23,13 @@
 import {
   AccountCapabilityInfo,
   AccountDetailInfo,
+  EditPreferencesInfo,
   PreferencesInfo,
 } from '../../types/common';
 import {
   createDefaultPreferences,
   createDefaultDiffPrefs,
+  createDefaultEditPrefs,
 } from '../../constants/constants';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {DiffPreferencesInfo} from '../../types/diff';
@@ -42,6 +44,7 @@
   account?: AccountDetailInfo;
   preferences: PreferencesInfo;
   diffPreferences: DiffPreferencesInfo;
+  editPreferences: EditPreferencesInfo;
   capabilities?: AccountCapabilityInfo;
 }
 
@@ -75,6 +78,11 @@
     userState => userState.diffPreferences
   );
 
+  readonly editPreferences$: Observable<EditPreferencesInfo> = select(
+    this.state$,
+    userState => userState.editPreferences
+  );
+
   readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
     this.preferences$,
     preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE
@@ -86,6 +94,7 @@
     super({
       preferences: createDefaultPreferences(),
       diffPreferences: createDefaultDiffPrefs(),
+      editPreferences: createDefaultEditPrefs(),
     });
     this.subscriptions = [
       from(this.restApiService.getAccount()).subscribe(
@@ -116,6 +125,16 @@
       this.account$
         .pipe(
           switchMap(account => {
+            if (!account) return of(createDefaultEditPrefs());
+            return from(this.restApiService.getEditPreferences());
+          })
+        )
+        .subscribe((editPrefs?: EditPreferencesInfo) => {
+          this.setEditPreferences(editPrefs ?? createDefaultEditPrefs());
+        }),
+      this.account$
+        .pipe(
+          switchMap(account => {
             if (!account) return of(undefined);
             return from(this.restApiService.getAccountCapabilities());
           })
@@ -145,11 +164,23 @@
   updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
     return this.restApiService
       .saveDiffPreferences(diffPrefs)
-      .then((response: Response) => {
+      .then((response: Response) =>
         this.restApiService.getResponseObject(response).then(obj => {
           const newPrefs = obj as unknown as DiffPreferencesInfo;
           if (!newPrefs) return;
           this.setDiffPreferences(newPrefs);
+        })
+      );
+  }
+
+  updateEditPreference(editPrefs: EditPreferencesInfo) {
+    return this.restApiService
+      .saveEditPreferences(editPrefs)
+      .then((response: Response) => {
+        this.restApiService.getResponseObject(response).then(obj => {
+          const newPrefs = obj as unknown as EditPreferencesInfo;
+          if (!newPrefs) return;
+          this.setEditPreferences(newPrefs);
         });
       });
   }
@@ -171,6 +202,11 @@
     this.subject$.next({...current, diffPreferences});
   }
 
+  setEditPreferences(editPreferences: EditPreferencesInfo) {
+    const current = this.subject$.getValue();
+    this.subject$.next({...current, editPreferences});
+  }
+
   setCapabilities(capabilities?: AccountCapabilityInfo) {
     const current = this.subject$.getValue();
     this.subject$.next({...current, capabilities});
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 0543cba..37f7a9f 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -35,12 +35,12 @@
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "^1.0.1",
-    "codemirror-minified": "^5.62.2",
+    "codemirror-minified": "^5.65.0",
     "immer": "^9.0.5",
-    "highlight.js": "^10.7.2",
+    "highlight.js": "^11.5.0",
     "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
     "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
-    "lit": "^2.1.1",
+    "lit": "^2.2.3",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
diff --git a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
deleted file mode 100644
index 1616ef3..0000000
--- a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
+++ /dev/null
@@ -1,146 +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.
- */
-
-/**
- * This plugin will a button to quickly add favorite reviewers to
- * reviewers in reply dialog.
- */
-
-const onToggleButtonClicks = [];
-function toggleButtonClicked(expanded) {
-  onToggleButtonClicks.forEach(cb => {
-    cb(expanded);
-  });
-}
-
-class ReviewerShortcut extends Polymer.Element {
-  static get is() { return 'reviewer-shortcut'; }
-
-  static get properties() {
-    return {
-      change: Object,
-      expanded: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <button on-click="toggleControlContent">
-        [[computeButtonText(expanded)]]
-      </button>
-    `;
-  }
-
-  toggleControlContent() {
-    this.expanded = !this.expanded;
-    toggleButtonClicked(this.expanded);
-  }
-
-  computeButtonText(expanded) {
-    return expanded ? 'Collapse' : 'Add favorite reviewers';
-  }
-}
-
-customElements.define(ReviewerShortcut.is, ReviewerShortcut);
-
-class ReviewerShortcutContent extends Polymer.Element {
-  static get is() { return 'reviewer-shortcut-content'; }
-
-  static get properties() {
-    return {
-      change: Object,
-      hidden: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-      },
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <style>
-      :host([hidden]) {
-        display: none;
-      }
-      :host {
-        display: block;
-      }
-      </style>
-      <ul>
-        <li><button on-click="addApple">Apple</button></li>
-        <li><button on-click="addBanana">Banana</button></li>
-        <li><button on-click="addCherry">Cherry</button></li>
-      </ul>
-    `;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    onToggleButtonClicks.push(expanded => {
-      this.hidden = !expanded;
-    });
-  }
-
-  addApple() {
-    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
-      reviewer: {
-        display_name: 'Apple',
-        email: 'apple@gmail.com',
-        name: 'Apple',
-        _account_id: 0,
-      },
-    },
-    composed: true, bubbles: true}));
-  }
-
-  addBanana() {
-    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
-      reviewer: {
-        display_name: 'Banana',
-        email: 'banana@gmail.com',
-        name: 'B',
-        _account_id: 1,
-      },
-    },
-    composed: true, bubbles: true}));
-  }
-
-  addCherry() {
-    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
-      reviewer: {
-        display_name: 'Cherry',
-        email: 'cherry@gmail.com',
-        name: 'C',
-        _account_id: 2,
-      },
-    },
-    composed: true, bubbles: true}));
-  }
-}
-
-customElements.define(ReviewerShortcutContent.is, ReviewerShortcutContent);
-
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'reply-reviewers', ReviewerShortcut.is, {slot: 'right'});
-  plugin.registerCustomComponent(
-      'reply-reviewers', ReviewerShortcutContent.is, {slot: 'below'});
-});
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
deleted file mode 100644
index 4527a80..0000000
--- a/polygerrit-ui/app/samples/bind-parameters.js
+++ /dev/null
@@ -1,63 +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.
- */
-class MyBindSample extends Polymer.Element {
-  static get is() { return 'my-bind-sample'; }
-
-  static get properties() {
-    return {
-      computedExample: {
-        type: String,
-        computed: '_computeExample(revision._number)',
-      },
-      revision: {
-        type: Object,
-        observer: '_onRevisionChanged',
-      },
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-    Template example: Patchset number [[revision._number]]. <br/>
-    Computed example: [[computedExample]].
-    `;
-  }
-
-  _computeExample(value) {
-    if (!value) { return '(empty)'; }
-    return `(patchset ${value} selected)`;
-  }
-
-  _onRevisionChanged(value) {
-    console.info(`(attributeHelper.bind) revision number: ${value._number}`);
-  }
-}
-
-// register the custom component
-customElements.define(MyBindSample.is, MyBindSample);
-
-/**
- * This plugin will add a new section
- * between the file list and change log with the
- * `my-bind-sample` component.
- */
-Gerrit.install(plugin => {
-  // You should see the above text with the right revision number shown
-  // between the file list and the change log
-  plugin.registerCustomComponent(
-      'change-view-integration', 'my-bind-sample');
-});
diff --git a/polygerrit-ui/app/samples/custom-wip-requirement.js b/polygerrit-ui/app/samples/custom-wip-requirement.js
deleted file mode 100644
index 1d2663c..0000000
--- a/polygerrit-ui/app/samples/custom-wip-requirement.js
+++ /dev/null
@@ -1,50 +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.
- */
-
-/**
- * This plugin will add a text next to WIP requirement if shown.
- */
-class WipRequirementValue extends Polymer.Element {
-  static get is() {
-    return 'wip-requirement-value';
-  }
-
-  static get template() {
-    return Polymer.html`
-        <style include="shared-styles">
-        :host {
-          color: var(--deemphasized-text-color);
-        }
-        </style>
-        <span>Will be removed once active.</span>
-      `;
-  }
-
-  static get properties() {
-    return {
-      change: Object,
-      requirement: Object,
-    };
-  }
-}
-
-customElements.define(WipRequirementValue.is, WipRequirementValue);
-
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'submit-requirement-item-wip', WipRequirementValue.is, {slot: 'value'});
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/extra-column-on-file-list.js b/polygerrit-ui/app/samples/extra-column-on-file-list.js
deleted file mode 100644
index c64bcd4..0000000
--- a/polygerrit-ui/app/samples/extra-column-on-file-list.js
+++ /dev/null
@@ -1,78 +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.
- */
-
-/**
- * This plugin will an extra column to file list on change page to show
- * the first character of the path.
- */
-
-// Header of this extra column
-class ColumnHeader extends Polymer.Element {
-  static get is() { return 'column-header'; }
-
-  static get template() {
-    return Polymer.html`
-      <style>
-      :host {
-        display: block;
-        padding-right: var(--spacing-m);
-        min-width: 5em;
-      }
-      </style>
-      <div>First Char</div>
-    `;
-  }
-}
-
-customElements.define(ColumnHeader.is, ColumnHeader);
-
-// Content of this extra column
-class ColumnContent extends Polymer.Element {
-  static get is() { return 'column-content'; }
-
-  static get properties() {
-    return {
-      path: String,
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <style>
-      :host {
-        display:block;
-        padding-right: var(--spacing-m);
-        min-width: 5em;
-      }
-      </style>
-      <div>[[getStatus(path)]]</div>
-    `;
-  }
-
-  getStatus(path) {
-    return path.charAt(0);
-  }
-}
-
-customElements.define(ColumnContent.is, ColumnContent);
-
-Gerrit.install(plugin => {
-  plugin.registerDynamicCustomComponent(
-      'change-view-file-list-header-prepend', ColumnHeader.is);
-  plugin.registerDynamicCustomComponent(
-      'change-view-file-list-content-prepend', ColumnContent.is);
-});
diff --git a/polygerrit-ui/app/samples/lgtm-plugin.js b/polygerrit-ui/app/samples/lgtm-plugin.js
deleted file mode 100644
index 537b1fa..0000000
--- a/polygerrit-ui/app/samples/lgtm-plugin.js
+++ /dev/null
@@ -1,33 +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.
- */
-
-/**
- * This plugin will +1 on Code-Review label if it detects that you have
- * LGTM as start of your reply.
- */
-Gerrit.install(plugin => {
-  const replyApi = plugin.changeReply();
-  replyApi.addReplyTextChangedCallback(text => {
-    const label = 'Code-Review';
-    const labelValue = replyApi.getLabelValue(label);
-    if (labelValue &&
-      labelValue === ' 0' &&
-      text.indexOf('LGTM') === 0) {
-      replyApi.setLabelValue(label, '+1');
-    }
-  });
-});
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
deleted file mode 100644
index 6abb120..0000000
--- a/polygerrit-ui/app/samples/repo-command.js
+++ /dev/null
@@ -1,60 +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.
- */
-
-class RepoCommandLow extends Polymer.Element {
-  static get is() { return 'repo-command-low'; }
-
-  static get properties() {
-    return {
-      rootUrl: String,
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-xxl);
-      }
-      </style>
-      <h3 class="heading-3">Plugin Bork</h3>
-      <gr-button on-click="_handleCommandTap">Bork</gr-button>
-    `;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    console.info(this.repoName);
-    console.info(this.config);
-    this.hidden = this.repoName !== 'All-Projects';
-  }
-
-  _handleCommandTap() {
-    alert('bork');
-  }
-}
-
-customElements.define(RepoCommandLow.is, RepoCommandLow);
-
-/**
- * This plugin adds a new command to the command page of the repo All-Projects.
- */
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'repo-command', 'repo-command-low');
-});
diff --git a/polygerrit-ui/app/samples/some-screen.js b/polygerrit-ui/app/samples/some-screen.js
deleted file mode 100644
index 8edaaa9..0000000
--- a/polygerrit-ui/app/samples/some-screen.js
+++ /dev/null
@@ -1,65 +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.
- */
-
-class SomeScreenMain extends Polymer.Element {
-  static get is() { return 'some-screen-main'; }
-
-  static get properties() {
-    return {
-      rootUrl: String,
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      This is the <b>main</b> plugin screen at [[token]]
-      <ul>
-        <li><a href$="[[rootUrl]]/bar">without component</a></li>
-      </ul>
-    `;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this.rootUrl = `${this.plugin.screenUrl()}`;
-  }
-}
-
-customElements.define(SomeScreenMain.is, SomeScreenMain);
-
-/**
- * This plugin will add several things to gerrit:
- * 1. two screens added by this plugin in two different ways
- * 2. a link in change page under meta info to the added main screen
- */
-Gerrit.install(plugin => {
-  // Recommended approach for screen() API.
-  plugin.screen('main', 'some-screen-main');
-
-  const mainUrl = plugin.screenUrl('main');
-
-  // Quick and dirty way to get something on screen.
-  plugin.screen('bar').onAttached(el => {
-    el.innerHTML = `This is a plugin screen at ${el.token}<br/>` +
-    `<a href="${mainUrl}">Go to main plugin screen</a>`;
-  });
-
-  // Add a "Plugin screen" link to the change view screen.
-  plugin.hook('change-metadata-item').onAttached(el => {
-    el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
-  });
-});
diff --git a/polygerrit-ui/app/samples/suggest-vote.js b/polygerrit-ui/app/samples/suggest-vote.js
deleted file mode 100644
index 10d0d4a..0000000
--- a/polygerrit-ui/app/samples/suggest-vote.js
+++ /dev/null
@@ -1,39 +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.
- */
-
-/**
- * This plugin will upgrade your +1 on Code-Review label
- * to +2 and show a message below the voting labels.
- */
-Gerrit.install(plugin => {
-  const replyApi = plugin.changeReply();
-  let wasSuggested = false;
-  plugin.on('showchange', () => {
-    wasSuggested = false;
-  });
-  const CODE_REVIEW = 'Code-Review';
-  replyApi.addLabelValuesChangedCallback(({name, value}) => {
-    if (wasSuggested && name === CODE_REVIEW) {
-      replyApi.showMessage('');
-      wasSuggested = false;
-    } else if (replyApi.getLabelValue(CODE_REVIEW) === '+1' && !wasSuggested) {
-      replyApi.setLabelValue(CODE_REVIEW, '+2');
-      replyApi.showMessage(`Suggested ${CODE_REVIEW} upgrade: +2`);
-      wasSuggested = true;
-    }
-  });
-});
diff --git a/polygerrit-ui/app/samples/theme-plugin.js b/polygerrit-ui/app/samples/theme-plugin.js
deleted file mode 100644
index b3d4033..0000000
--- a/polygerrit-ui/app/samples/theme-plugin.js
+++ /dev/null
@@ -1,49 +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.
- */
-const customTheme = document.createElement('dom-module');
-customTheme.innerHTML = `
-  <template>
-    <style>
-    html {
-      --primary-text-color: red;
-    }
-    </style>
-  </template>
-`;
-customTheme.register('theme-plugin');
-
-const darkCustomTheme = document.createElement('dom-module');
-darkCustomTheme.innerHTML = `
-  <template>
-    <style>
-    html {
-      --background-color-primary: yellow;
-    }
-    </style>
-  </template>
-`;
-darkCustomTheme.register('dark-theme-plugin');
-
-/**
- * This plugin will change the primary text color to red.
- *
- * Also change the primary background color to yellow for dark theme.
- */
-Gerrit.install(plugin => {
-  plugin.registerStyleModule('app-theme', 'theme-plugin');
-  plugin.registerStyleModule('app-theme-dark', 'dark-theme-plugin');
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
deleted file mode 100644
index 5818003..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {getAccountDisplayName} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {AccountInfo} from '../../types/common';
-
-export class GrEmailSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
-      if (!accounts) {
-        return [];
-      }
-      return accounts;
-    });
-  }
-
-  makeSuggestionItem(account: AccountInfo) {
-    return {
-      name: getAccountDisplayName(undefined, account),
-      value: {account, count: 1},
-    };
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
deleted file mode 100644
index 223c2ab..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
-import {getAppContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrEmailSuggestionsProvider tests', () => {
-  let provider;
-  const account1 = {
-    name: 'Some name',
-    email: 'some@example.com',
-  };
-  const account2 = {
-    email: 'other@example.com',
-    _account_id: 3,
-  };
-
-  setup(() => {
-    provider = new GrEmailSuggestionsProvider(getAppContext().restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    const getSuggestedAccountsStub =
-        stubRestApi('getSuggestedAccounts').returns(
-            Promise.resolve([account1, account2]));
-
-    const res = await provider.getSuggestions('Some input');
-    assert.deepEqual(res, [account1, account2]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(account1), {
-      name: 'Some name <some@example.com>',
-      value: {
-        account: account1,
-        count: 1,
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(account2), {
-      name: 'other@example.com <other@example.com>',
-      value: {
-        account: account2,
-        count: 1,
-      },
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
deleted file mode 100644
index ff113fb..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {GroupBaseInfo} from '../../types/common';
-
-export class GrGroupSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedGroups(`${input}`).then(groups => {
-      if (!groups) {
-        return [];
-      }
-      const keys = Object.keys(groups);
-      return keys.map(key => {
-        return {...groups[key], name: key};
-      });
-    });
-  }
-
-  makeSuggestionItem(suggestion: GroupBaseInfo) {
-    return {
-      name: suggestion.name,
-      value: {group: {name: suggestion.name, id: suggestion.id}},
-    };
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
deleted file mode 100644
index e643166..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
-import {getAppContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrGroupSuggestionsProvider tests', () => {
-  let provider;
-  const group1 = {
-    name: 'Some name',
-    id: 1,
-  };
-  const group2 = {
-    name: 'Other name',
-    id: 3,
-    url: 'abcd',
-  };
-
-  setup(() => {
-    provider = new GrGroupSuggestionsProvider(getAppContext().restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    const getSuggestedAccountsStub =
-        stubRestApi('getSuggestedGroups')
-            .returns(Promise.resolve({
-              'Some name': {id: 1},
-              'Other name': {id: 3, url: 'abcd'},
-            }));
-
-    const res = await provider.getSuggestions('Some input');
-    assert.deepEqual(res, [group1, group2]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(group1), {
-      name: 'Some name',
-      value: {
-        group: {
-          name: 'Some name',
-          id: 1,
-        },
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(group2), {
-      name: 'Other name',
-      value: {
-        group: {
-          name: 'Other name',
-          id: 3,
-        },
-      },
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index a74adf6..5cb57aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -28,99 +28,55 @@
   SuggestedReviewerInfo,
   Suggestion,
 } from '../../types/common';
-import {assertNever} from '../../utils/common-util';
-
-// TODO(TS): enum name doesn't follow typescript style guid rules
-// Rename it
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
-  REVIEWER = 'reviewers',
-  CC = 'ccs',
-  ANY = 'any',
-}
-
-export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
-  return (s as AccountInfo)._account_id !== undefined;
-}
-
-type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
-
-export interface SuggestionItem {
-  name: string;
-  value: SuggestedReviewerInfo;
-}
+import {assertNever, intersection} from '../../utils/common-util';
+import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
+import {allSettled, isFulfilled} from '../../utils/async-util';
+import {notUndefined} from '../../types/types';
+import {accountKey} from '../../utils/account-util';
+import {ReviewerState} from '../../api/rest-api';
 
 export interface ReviewerSuggestionsProvider {
-  init(): void;
   getSuggestions(input: string): Promise<Suggestion[]>;
-  makeSuggestionItem(suggestion: Suggestion): SuggestionItem;
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo>;
 }
 
 export class GrReviewerSuggestionsProvider
   implements ReviewerSuggestionsProvider
 {
-  static create(
-    restApi: RestApiService,
-    changeNumber: NumericChangeId,
-    userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+  private changeNumbers: NumericChangeId[];
+
+  constructor(
+    private restApi: RestApiService,
+    private type: ReviewerState.REVIEWER | ReviewerState.CC,
+    private config: ServerInfo | undefined,
+    private loggedIn: boolean,
+    ...changeNumbers: NumericChangeId[]
   ) {
-    switch (userType) {
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedReviewers(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedCCs(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
-        );
-      default:
-        throw new Error(`Unknown users type: ${userType}`);
-    }
+    this.changeNumbers = changeNumbers;
   }
 
-  private initPromise?: Promise<void>;
+  async getSuggestions(input: string): Promise<Suggestion[]> {
+    if (!this.loggedIn) return [];
 
-  private config?: ServerInfo;
-
-  private loggedIn = false;
-
-  private initialized = false;
-
-  private constructor(
-    private readonly _restAPI: RestApiService,
-    private readonly _apiCall: ApiCallCallback
-  ) {}
-
-  init() {
-    if (this.initPromise) {
-      return this.initPromise;
-    }
-    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this.config = cfg;
-    });
-    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
-      () => {
-        this.initialized = true;
-      }
+    const allResults = await allSettled(
+      this.changeNumbers.map(changeNumber =>
+        this.getSuggestionsForChange(changeNumber, input)
+      )
     );
-    return this.initPromise;
+    const allSuggestions = allResults
+      .filter(isFulfilled)
+      .map(result => result.value)
+      .filter(notUndefined);
+    return intersection(allSuggestions, (s1, s2) =>
+      this.areSameSuggestions(s1, s2)
+    );
   }
 
-  getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this.initialized || !this.loggedIn) {
-      return Promise.resolve([]);
-    }
-
-    return this._apiCall(input).then(reviewers => reviewers || []);
-  }
-
-  makeSuggestionItem(suggestion: Suggestion): SuggestionItem {
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
     if (isReviewerAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getChangeSuggestedReviewers.
       return {
@@ -137,7 +93,7 @@
       };
     }
 
-    if (isAccountSuggestions(suggestion)) {
+    if (this.isAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
         name: getAccountDisplayName(this.config, suggestion),
@@ -146,4 +102,28 @@
     }
     assertNever(suggestion, 'Received an incorrect suggestion');
   }
+
+  private getSuggestionsForChange(
+    changeNumber: NumericChangeId,
+    input: string
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    return this.type === ReviewerState.REVIEWER
+      ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
+      : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+  }
+
+  private areSameSuggestions(a: Suggestion, b: Suggestion): boolean {
+    if (isReviewerAccountSuggestion(a) && isReviewerAccountSuggestion(b)) {
+      return accountKey(a.account) === accountKey(b.account);
+    } else if (isReviewerGroupSuggestion(a) && isReviewerGroupSuggestion(b)) {
+      return a.group.id === b.group.id;
+    } else if (this.isAccountSuggestion(a) && this.isAccountSuggestion(b)) {
+      return accountKey(a) === accountKey(b);
+    }
+    return false;
+  }
+
+  private isAccountSuggestion(s: Suggestion): s is AccountInfo {
+    return (s as AccountInfo)._account_id !== undefined;
+  }
 }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
deleted file mode 100644
index 1916822..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
+++ /dev/null
@@ -1,225 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-import {getAppContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrReviewerSuggestionsProvider tests', () => {
-  let _nextAccountId = 0;
-  const makeAccount = function(opt_status) {
-    const accountId = ++_nextAccountId;
-    return {
-      _account_id: accountId,
-      name: 'name ' + accountId,
-      email: 'email ' + accountId,
-      status: opt_status,
-    };
-  };
-  let _nextAccountId2 = 0;
-  const makeAccount2 = function(opt_status) {
-    const accountId2 = ++_nextAccountId2;
-    return {
-      _account_id: accountId2,
-      name: 'name ' + accountId2,
-      status: opt_status,
-    };
-  };
-
-  let owner;
-  let existingReviewer1;
-  let existingReviewer2;
-  let suggestion1;
-  let suggestion2;
-  let suggestion3;
-  let provider;
-
-  let redundantSuggestion1;
-  let redundantSuggestion2;
-  let redundantSuggestion3;
-  let change;
-
-  setup(async () => {
-    owner = makeAccount();
-    existingReviewer1 = makeAccount();
-    existingReviewer2 = makeAccount();
-    suggestion1 = {account: makeAccount()};
-    suggestion2 = {account: makeAccount()};
-    suggestion3 = {
-      group: {
-        id: 'suggested group id',
-        name: 'suggested group',
-      },
-    };
-
-    stubRestApi('getConfig').returns(Promise.resolve({}));
-
-    change = {
-      _number: 42,
-      owner,
-      reviewers: {
-        CC: [existingReviewer1],
-        REVIEWER: [existingReviewer2],
-      },
-    };
-
-    await flush();
-  });
-
-  suite('allowAnyUser set to false', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          getAppContext().restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      await provider.init();
-    });
-    suite('stubbed values for _getReviewerSuggestions', () => {
-      let getChangeSuggestedReviewersStub;
-      setup(() => {
-        getChangeSuggestedReviewersStub =
-            stubRestApi('getChangeSuggestedReviewers').callsFake(() => {
-              redundantSuggestion1 = {account: existingReviewer1};
-              redundantSuggestion2 = {account: existingReviewer2};
-              redundantSuggestion3 = {account: owner};
-              return Promise.resolve([
-                redundantSuggestion1, redundantSuggestion2,
-                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-            });
-      });
-
-      test('makeSuggestionItem formats account or group accordingly', () => {
-        let account = makeAccount();
-        const account3 = makeAccount2();
-        let suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account},
-        });
-
-        const group = {name: 'test'};
-        suggestion = provider.makeSuggestionItem({group});
-        assert.deepEqual(suggestion, {
-          name: group.name + ' (group)',
-          value: {group},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous',
-          value: {account: {}},
-        });
-
-        provider.config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward Name',
-          },
-        };
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous Coward Name',
-          value: {account: {}},
-        });
-
-        account = makeAccount('OOO');
-
-        suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account, count: 1},
-        });
-
-        account3.email = undefined;
-
-        suggestion = provider.makeSuggestionItem(account3);
-        assert.deepEqual(suggestion, {
-          name: account3.name,
-          value: {account: account3, count: 1},
-        });
-      });
-
-      test('getSuggestions', async () => {
-        const reviewers = await provider.getSuggestions();
-
-        // Default is no filtering.
-        assert.equal(reviewers.length, 6);
-        assert.deepEqual(reviewers,
-            [redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1,
-              suggestion2, suggestion3]);
-      });
-
-      test('getSuggestions short circuits when logged out', () => {
-        provider.loggedIn = false;
-        return provider.getSuggestions('').then(() => {
-          assert.isFalse(getChangeSuggestedReviewersStub.called);
-          provider.loggedIn = true;
-          return provider.getSuggestions('').then(() => {
-            assert.isTrue(getChangeSuggestedReviewersStub.called);
-          });
-        });
-      });
-    });
-
-    test('getChangeSuggestedReviewers is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isTrue(suggestReviewerStub.calledOnce);
-      assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-      assert.isFalse(suggestAccountStub.called);
-    });
-  });
-
-  suite('allowAnyUser set to true', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          getAppContext().restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      await provider.init();
-    });
-
-    test('getSuggestedAccounts is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isFalse(suggestReviewerStub.called);
-      assert.isTrue(suggestAccountStub.calledOnce);
-      assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
new file mode 100644
index 0000000..3dc30dd
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -0,0 +1,212 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma';
+import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  ChangeInfo,
+  GroupId,
+  GroupName,
+  NumericChangeId,
+  ReviewerState,
+} from '../../api/rest-api';
+import {
+  SuggestedReviewerAccountInfo,
+  SuggestedReviewerGroupInfo,
+} from '../../types/common';
+import {
+  createAccountDetailWithIdNameAndEmail,
+  createChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
+
+suite('GrReviewerSuggestionsProvider tests', () => {
+  const suggestion1: SuggestedReviewerAccountInfo = {
+    account: createAccountDetailWithIdNameAndEmail(3),
+    count: 1,
+  };
+  const suggestion2: SuggestedReviewerAccountInfo = {
+    account: createAccountDetailWithIdNameAndEmail(4),
+    count: 1,
+  };
+  const suggestion3: SuggestedReviewerGroupInfo = {
+    group: {
+      id: 'suggested group id' as GroupId,
+      name: 'suggested group' as GroupName,
+    },
+    count: 4,
+  };
+  const change: ChangeInfo = createChange();
+  let getChangeSuggestedReviewersStub: sinon.SinonStub;
+  let getChangeSuggestedCCsStub: sinon.SinonStub;
+  let provider: GrReviewerSuggestionsProvider;
+
+  setup(() => {
+    getChangeSuggestedReviewersStub = stubRestApi(
+      'getChangeSuggestedReviewers'
+    ).resolves([suggestion1, suggestion2, suggestion3]);
+    getChangeSuggestedCCsStub = stubRestApi('getChangeSuggestedCCs').resolves([
+      suggestion1,
+      suggestion2,
+      suggestion3,
+    ]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      change._number
+    );
+  });
+
+  test('getSuggestions', async () => {
+    const reviewers = await provider.getSuggestions('');
+
+    assert.sameDeepMembers(reviewers, [suggestion1, suggestion2, suggestion3]);
+  });
+
+  test('getSuggestions short circuits when logged out', async () => {
+    await provider.getSuggestions('');
+    assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+
+    // not logged in
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      false,
+      change._number
+    );
+
+    await provider.getSuggestions('');
+
+    // no additional call is made
+    assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+  });
+
+  test('only returns REVIEWER suggestions shared by all changes', async () => {
+    getChangeSuggestedReviewersStub
+      .onSecondCall()
+      .resolves([suggestion2, suggestion3]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      ...[change._number, 43 as NumericChangeId]
+    );
+
+    // suggestion1 is excluded because it is not returned for the second
+    // change.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestion2,
+      suggestion3,
+    ]);
+  });
+
+  test('only returns CC suggestions shared by all changes', async () => {
+    getChangeSuggestedCCsStub
+      .onSecondCall()
+      .resolves([suggestion2, suggestion3]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.CC,
+      createServerInfo(),
+      true,
+      ...[change._number, 43 as NumericChangeId]
+    );
+
+    // suggestion1 is excluded because it is not returned for the second
+    // change.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestion2,
+      suggestion3,
+    ]);
+  });
+
+  test('makeSuggestionItem formats account or group accordingly', () => {
+    let account = createAccountDetailWithIdNameAndEmail(1);
+    const account3 = createAccountDetailWithIdNameAndEmail(2);
+    let suggestion = provider.makeSuggestionItem({account, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}>`,
+      value: {account, count: 1},
+    });
+
+    const group = {name: 'test' as GroupName, id: '5' as GroupId};
+    suggestion = provider.makeSuggestionItem({group, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${group.name} (group)`,
+      value: {group, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(account);
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}>`,
+      value: {account, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Name of user not set',
+      value: {account: {}, count: 1},
+    });
+
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward Name',
+        },
+      },
+      true,
+      change._number
+    );
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Anonymous Coward Name',
+      value: {account: {}, count: 1},
+    });
+
+    account = {...createAccountDetailWithIdNameAndEmail(3), status: 'OOO'};
+
+    suggestion = provider.makeSuggestionItem({account, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}> (OOO)`,
+      value: {account, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(account);
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}> (OOO)`,
+      value: {account, count: 1},
+    });
+
+    account3.email = undefined;
+
+    suggestion = provider.makeSuggestionItem(account3);
+    assert.deepEqual(suggestion, {
+      name: account3.name,
+      value: {account: account3, count: 1},
+    });
+  });
+});
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
index bf7120f..59ee020 100644
--- a/polygerrit-ui/app/scripts/util.ts
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -19,59 +19,40 @@
   cancel(): void;
 }
 
-// TODO (dmfilippov): Each function must be exported separately. According to
-// the code style guide, a namespacing is not allowed.
-export const util = {
-  getCookie(name: string) {
-    const key = name + '=';
-    const cookies = document.cookie.split(';');
-    for (let i = 0; i < cookies.length; i++) {
-      let c = cookies[i];
-      while (c.charAt(0) === ' ') {
-        c = c.substring(1);
-      }
-      if (c.startsWith(key)) {
-        return c.substring(key.length, c.length);
-      }
+/**
+ * Make the promise cancelable.
+ *
+ * Returns a promise with a `cancel()` method wrapped around `promise`.
+ * Calling `cancel()` will reject the returned promise with
+ * {isCancelled: true} synchronously. If the inner promise for a cancelled
+ * promise resolves or rejects this is ignored.
+ */
+export function makeCancelable<T>(promise: Promise<T>) {
+  // True if the promise is either resolved or reject (possibly cancelled)
+  let isDone = false;
+
+  let rejectPromise: (reason?: unknown) => void;
+
+  const wrappedPromise: CancelablePromise<T> = new Promise(
+    (resolve, reject) => {
+      rejectPromise = reject;
+      promise.then(
+        val => {
+          if (!isDone) resolve(val);
+          isDone = true;
+        },
+        error => {
+          if (!isDone) reject(error);
+          isDone = true;
+        }
+      );
     }
-    return '';
-  },
+  ) as CancelablePromise<T>;
 
-  /**
-   * Make the promise cancelable.
-   *
-   * Returns a promise with a `cancel()` method wrapped around `promise`.
-   * Calling `cancel()` will reject the returned promise with
-   * {isCancelled: true} synchronously. If the inner promise for a cancelled
-   * promise resolves or rejects this is ignored.
-   */
-  makeCancelable<T>(promise: Promise<T>) {
-    // True if the promise is either resolved or reject (possibly cancelled)
-    let isDone = false;
-
-    let rejectPromise: (reason?: unknown) => void;
-
-    const wrappedPromise: CancelablePromise<T> = new Promise(
-      (resolve, reject) => {
-        rejectPromise = reject;
-        promise.then(
-          val => {
-            if (!isDone) resolve(val);
-            isDone = true;
-          },
-          error => {
-            if (!isDone) reject(error);
-            isDone = true;
-          }
-        );
-      }
-    ) as CancelablePromise<T>;
-
-    wrappedPromise.cancel = () => {
-      if (isDone) return;
-      rejectPromise({isCanceled: true});
-      isDone = true;
-    };
-    return wrappedPromise;
-  },
-};
+  wrappedPromise.cancel = () => {
+    if (isDone) return;
+    rejectPromise({isCanceled: true});
+    isDone = true;
+  };
+  return wrappedPromise;
+}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 10aa266..cc52fc8 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -21,7 +21,7 @@
 import {GrReporting} from './gr-reporting/gr-reporting_impl';
 import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
-import {GrRestApiServiceImpl} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
+import {GrRestApiServiceImpl} from './gr-rest-api/gr-rest-api-impl';
 import {ChangeModel, changeModelToken} from '../models/change/change-model';
 import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
@@ -49,34 +49,37 @@
       new FlagsServiceImplementation(),
     reportingService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.flagsService, 'flagsService)');
-      return new GrReporting(ctx.flagsService!);
+      return new GrReporting(ctx.flagsService);
     },
     eventEmitter: (_ctx: Partial<AppContext>) => new EventEmitter(),
     authService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.eventEmitter, 'eventEmitter');
-      return new Auth(ctx.eventEmitter!);
+      return new Auth(ctx.eventEmitter);
     },
     restApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.authService, 'authService');
-      return new GrRestApiServiceImpl(ctx.authService!);
+      return new GrRestApiServiceImpl(ctx.authService);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       const reportingService = ctx.reportingService;
       assertIsDefined(reportingService, 'reportingService');
-      return new GrJsApiInterface(reportingService!);
+      return new GrJsApiInterface(reportingService);
     },
     storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
     userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserModel(ctx.restApiService!);
+      return new UserModel(ctx.restApiService);
     },
     shortcutsService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.userModel, 'userModel');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.userModel, ctx.reportingService!);
+      return new ShortcutsService(ctx.userModel, ctx.reportingService);
     },
     pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
-    highlightService: (_ctx: Partial<AppContext>) => new HighlightService(),
+    highlightService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.reportingService, 'reportingService');
+      return new HighlightService(ctx.reportingService);
+    },
   };
   return create<AppContext>(appRegistry);
 }
@@ -85,7 +88,7 @@
   appContext: AppContext
 ): Map<DependencyToken<unknown>, Finalizable> {
   const dependencies = new Map<DependencyToken<unknown>, Finalizable>();
-  const browserModel = new BrowserModel(appContext.userModel!);
+  const browserModel = new BrowserModel(appContext.userModel);
   dependencies.set(browserModelToken, browserModel);
 
   const changeModel = new ChangeModel(
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 0edc13d..44d63d4b 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -23,14 +23,11 @@
 }
 
 /**
- * @desc Experiment ids used in Gerrit.
+ * Experiment ids used in Gerrit.
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
-  SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
   BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
-  CHECK_RESULTS_IN_DIFFS = 'UiFeature__check_results_in_diffs',
   DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
-  SYNTAX_LAYER_WORKER = 'UiFeature__syntax_layer_worker',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 5f77e8a..028b2af 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -62,7 +62,7 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<boolean>;
 
   private _last_auth_check_time: number = Date.now();
 
@@ -100,37 +100,37 @@
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`)
+        .then(res => {
+          // Make a call that requires loading the body of the request. This makes it so that the browser
+          // can close the request even though callers of this method might only ever read headers.
+          // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+          try {
+            res.clone().text();
+          } catch {
+            // Ignore error
+          }
+
+          // auth-check will return 204 if authed
+          // treat the rest as unauthed
+          if (res.status === 204) {
+            this._setStatus(Auth.STATUS.AUTHED);
+            return true;
+          } else {
+            this._setStatus(Auth.STATUS.NOT_AUTHED);
+            return false;
+          }
+        })
+        .catch(() => {
+          this._setStatus(AuthStatus.ERROR);
+          // Reset authCheckPromise to avoid caching the failed promise
+          this.authCheckPromise = undefined;
+          return false;
+        });
       this._last_auth_check_time = Date.now();
     }
 
-    return this.authCheckPromise
-      .then(res => {
-        // Make a call that requires loading the body of the request. This makes it so that the browser
-        // can close the request even though callers of this method might only ever read headers.
-        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
-        try {
-          res.clone().text();
-        } catch {
-          // Ignore error
-        }
-
-        // auth-check will return 204 if authed
-        // treat the rest as unauthed
-        if (res.status === 204) {
-          this._setStatus(Auth.STATUS.AUTHED);
-          return true;
-        } else {
-          this._setStatus(Auth.STATUS.NOT_AUTHED);
-          return false;
-        }
-      })
-      .catch(() => {
-        this._setStatus(AuthStatus.ERROR);
-        // Reset authCheckPromise to avoid caching the failed promise
-        this.authCheckPromise = undefined;
-        return false;
-      });
+    return this.authCheckPromise;
   }
 
   clearCache() {
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
index 391a32b..057cd3e 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
@@ -50,7 +50,7 @@
    * the event named eventName, in the order they were registered,
    * passing the supplied detail to each.
    *
-   * @returns true if the event had listeners, false otherwise.
+   * @return true if the event had listeners, false otherwise.
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   emit(eventName: string, detail: any): boolean;
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
index 19d2f61..055687f 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
@@ -99,7 +99,7 @@
    * the event named eventName, in the order they were registered,
    * passing the supplied detail to each.
    *
-   * @returns true if the event had listeners, false otherwise.
+   * @return true if the event had listeners, false otherwise.
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   emit(eventName: string, detail: any): boolean {
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index b035bb1..aeb1614 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -51,7 +51,6 @@
   changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
-  diffViewFullyLoaded(): void;
   diffViewContentDisplayed(): void;
   fileListDisplayed(): void;
   reportExtension(name: string): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 0d29d8c..f27ab96 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -91,7 +91,6 @@
   [Timing.STARTUP_DASHBOARD_DISPLAYED]: 0,
   [Timing.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED]: 0,
   [Timing.STARTUP_DIFF_VIEW_DISPLAYED]: 0,
-  [Timing.STARTUP_DIFF_VIEW_LOAD_FULL]: 0,
   [Timing.STARTUP_FILE_LIST_DISPLAYED]: 0,
   [Timing.APP_STARTED]: 0,
   // WebComponentsReady timer is triggered from gr-router.
@@ -363,16 +362,18 @@
   }
 
   private _reportEvent(eventInfo: EventInfo, opt_noLog?: boolean) {
-    const {type, value, name} = eventInfo;
+    const {type, value, name, eventDetails} = eventInfo;
     document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
     if (opt_noLog) {
       return;
     }
     if (type !== ERROR.TYPE) {
       if (value !== undefined) {
-        console.info(`Reporting: ${name}: ${value}`);
+        console.debug(`Reporting: ${name}: ${value}`);
+      } else if (eventDetails !== undefined) {
+        console.debug(`Reporting: ${name}: ${eventDetails}`);
       } else {
-        console.info(`Reporting: ${name}`);
+        console.debug(`Reporting: ${name}`);
       }
     }
   }
@@ -490,7 +491,6 @@
     this.time(Timing.DASHBOARD_DISPLAYED);
     this.time(Timing.DIFF_VIEW_CONTENT_DISPLAYED);
     this.time(Timing.DIFF_VIEW_DISPLAYED);
-    this.time(Timing.DIFF_VIEW_LOAD_FULL);
     this.time(Timing.FILE_LIST_DISPLAYED);
     this.reportRepoName = undefined;
     this.reportChangeId = undefined;
@@ -541,14 +541,6 @@
     }
   }
 
-  diffViewFullyLoaded() {
-    if (hasOwnProperty(this._baselines, Timing.STARTUP_DIFF_VIEW_LOAD_FULL)) {
-      this.timeEnd(Timing.STARTUP_DIFF_VIEW_LOAD_FULL);
-    } else {
-      this.timeEnd(Timing.DIFF_VIEW_LOAD_FULL);
-    }
-  }
-
   diffViewContentDisplayed() {
     if (
       hasOwnProperty(
@@ -624,7 +616,7 @@
       LifeCycle.PLUGINS_INSTALLED,
       undefined,
       {pluginsList: pluginsList || []},
-      true
+      false
     );
   }
 
@@ -636,7 +628,7 @@
       LifeCycle.PLUGINS_FAILED,
       undefined,
       {pluginsList: pluginsList || []},
-      true
+      false
     );
   }
 
@@ -765,7 +757,7 @@
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
@@ -776,7 +768,7 @@
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
@@ -834,7 +826,7 @@
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 01454c6..f361782 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -46,7 +46,6 @@
   dashboardDisplayed: () => {},
   diffViewContentDisplayed: () => {},
   diffViewDisplayed: () => {},
-  diffViewFullyLoaded: () => {},
   fileListDisplayed: () => {},
   finalize: () => {},
   getTimer: () => new MockTimer(),
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
similarity index 72%
rename from polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
rename to polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
index 73f8580..b9615b8 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
@@ -15,18 +15,19 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {GrReporting} from './gr-reporting_impl.js';
-import {grReportingMock} from './gr-reporting_mock.js';
+import '../../test/common-test-setup-karma';
+import {GrReporting} from './gr-reporting_impl';
+import {grReportingMock} from './gr-reporting_mock';
+
 suite('gr-reporting_mock tests', () => {
   test('mocks all public methods', () => {
     const methods = Object.getOwnPropertyNames(GrReporting.prototype)
-        .filter(name => typeof GrReporting.prototype[name] === 'function')
-        .filter(name => !name.startsWith('_') && name !== 'constructor')
-        .sort();
-    const mockMethods = Object.getOwnPropertyNames(grReportingMock)
-        .sort();
+      .filter(
+        name => typeof (GrReporting as any).prototype[name] === 'function'
+      )
+      .filter(name => !name.startsWith('_') && name !== 'constructor')
+      .sort();
+    const mockMethods = Object.getOwnPropertyNames(grReportingMock).sort();
     assert.deepEqual(methods, mockMethods);
   });
 });
-
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
similarity index 69%
rename from polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
rename to polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
index f7612cd..13c96c8 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -15,15 +15,23 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting_impl.js';
-import {getAppContext} from '../app-context.js';
-import {Deduping} from '../../api/reporting.js';
-suite('gr-reporting tests', () => {
-  let service;
+import '../../test/common-test-setup-karma';
+import {
+  GrReporting,
+  DEFAULT_STARTUP_TIMERS,
+  initErrorReporter,
+} from './gr-reporting_impl';
+import {getAppContext} from '../app-context';
+import {Deduping} from '../../api/reporting';
+import {SinonFakeTimers} from 'sinon';
 
-  let clock;
-  let fakePerformance;
+suite('gr-reporting tests', () => {
+  // We have to type as any because we access
+  // private properties for testing.
+  let service: any;
+
+  let clock: SinonFakeTimers;
+  let fakePerformance: any;
 
   const NOW_TIME = 100;
 
@@ -48,23 +56,36 @@
     sinon.stub(window.performance, 'now').returns(42);
     service.appStarted();
     assert.isTrue(
-        service.reporter.calledWithMatch(
-            'timing-report', 'UI Latency', 'App Started', 42
-        ));
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'App Started',
+        42
+      )
+    );
     assert.isTrue(
-        service.reporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
-            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
-            undefined, true)
+      service.reporter.calledWithExactly(
+        'timing-report',
+        'UI Latency',
+        'NavResTime - loadEventEnd',
+        fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+        undefined,
+        true
+      )
     );
   });
 
   test('WebComponentsReady', () => {
     sinon.stub(window.performance, 'now').returns(42);
     service.timeEnd('WebComponentsReady');
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'WebComponentsReady', 42
-    ));
+    assert.isTrue(
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'WebComponentsReady',
+        42
+      )
+    );
   });
 
   test('beforeLocationChanged', () => {
@@ -76,7 +97,9 @@
     assert.isTrue(service.time.calledWithExactly('ChangeFullyLoaded'));
     assert.isTrue(service.time.calledWithExactly('DiffViewDisplayed'));
     assert.isTrue(service.time.calledWithExactly('FileListDisplayed'));
-    assert.isFalse(service._baselines.hasOwnProperty('garbage'));
+    assert.isFalse(
+      Object.prototype.hasOwnProperty.call(service._baselines, 'garbage')
+    );
   });
 
   test('changeDisplayed', () => {
@@ -91,10 +114,10 @@
   test('changeFullyLoaded', () => {
     sinon.spy(service, 'timeEnd');
     service.changeFullyLoaded();
-    assert.isFalse(
-        service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+    assert.isFalse(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
     assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
+      service.timeEnd.calledWithExactly('StartupChangeFullyLoaded')
+    );
     service.changeFullyLoaded();
     assert.isTrue(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
   });
@@ -111,10 +134,10 @@
   test('fileListDisplayed', () => {
     sinon.spy(service, 'timeEnd');
     service.fileListDisplayed();
-    assert.isFalse(
-        service.timeEnd.calledWithExactly('FileListDisplayed'));
+    assert.isFalse(service.timeEnd.calledWithExactly('FileListDisplayed'));
     assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupFileListDisplayed'));
+      service.timeEnd.calledWithExactly('StartupFileListDisplayed')
+    );
     service.fileListDisplayed();
     assert.isTrue(service.timeEnd.calledWithExactly('FileListDisplayed'));
   });
@@ -130,46 +153,49 @@
 
   test('dashboardDisplayed details', () => {
     sinon.spy(service, 'timeEnd');
-    sinon.stub(window, 'performance').value( {
+    sinon.stub(window, 'performance').value({
       memory: {
         usedJSHeapSize: 1024 * 1024,
       },
       measure: () => {},
-      now: () => { 42; },
+      now: () => {
+        42;
+      },
     });
     service.reportRpcTiming('/changes/*~*/comments', 500);
     service.dashboardDisplayed();
     assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupDashboardDisplayed',
-            {rpcList: [
-              {
-                anonymizedUrl: '/changes/*~*/comments',
-                elapsed: 500,
-              },
-            ],
-            screenSize: {
-              width: window.screen.width,
-              height: window.screen.height,
-            },
-            viewport: {
-              width: document.documentElement.clientWidth,
-              height: document.documentElement.clientHeight,
-            },
-            usedJSHeapSizeMb: 1,
-            hiddenDurationMs: 0,
-            }
-        ));
+      service.timeEnd.calledWithExactly('StartupDashboardDisplayed', {
+        rpcList: [
+          {
+            anonymizedUrl: '/changes/*~*/comments',
+            elapsed: 500,
+          },
+        ],
+        screenSize: {
+          width: window.screen.width,
+          height: window.screen.height,
+        },
+        viewport: {
+          width: document.documentElement.clientWidth,
+          height: document.documentElement.clientHeight,
+        },
+        usedJSHeapSizeMb: 1,
+        hiddenDurationMs: 0,
+      })
+    );
   });
 
   suite('hidden duration', () => {
-    let nowStub;
-    let visibilityStateStub;
-    const assertHiddenDurationsMs = hiddenDurationMs => {
+    let nowStub: sinon.SinonStub;
+    let visibilityStateStub: sinon.SinonStub;
+    const assertHiddenDurationsMs = (hiddenDurationMs: number) => {
       service.dashboardDisplayed();
       assert.isTrue(
-          service.timeEnd.calledWithMatch('StartupDashboardDisplayed',
-              {hiddenDurationMs}
-          ));
+        service.timeEnd.calledWithMatch('StartupDashboardDisplayed', {
+          hiddenDurationMs,
+        })
+      );
     };
 
     setup(() => {
@@ -177,10 +203,12 @@
       nowStub = sinon.stub(window.performance, 'now');
       visibilityStateStub = {
         value: value => {
-          Object.defineProperty(document, 'visibilityState',
-              {value, configurable: true});
+          Object.defineProperty(document, 'visibilityState', {
+            value,
+            configurable: true,
+          });
         },
-      };
+      } as sinon.SinonStub;
     });
 
     test('starts in hidden', () => {
@@ -229,9 +257,10 @@
       service.timeEnd.resetHistory();
       service.dashboardDisplayed();
       assert.isTrue(
-          service.timeEnd.calledWithMatch('DashboardDisplayed',
-              {hiddenDurationMs: 0}
-          ));
+        service.timeEnd.calledWithMatch('DashboardDisplayed', {
+          hiddenDurationMs: 0,
+        })
+      );
     });
   });
 
@@ -244,12 +273,12 @@
     service.timeEnd('bar');
     nowStub.returns(3);
     service.timeEnd('foo');
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo', 3
-    ));
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'bar', 1
-    ));
+    assert.isTrue(
+      service.reporter.calledWithMatch('timing-report', 'UI Latency', 'foo', 3)
+    );
+    assert.isTrue(
+      service.reporter.calledWithMatch('timing-report', 'UI Latency', 'bar', 1)
+    );
   });
 
   test('timer object', () => {
@@ -257,8 +286,14 @@
     const timer = service.getTimer('foo-bar');
     nowStub.returns(150);
     timer.end();
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo-bar', 50));
+    assert.isTrue(
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'foo-bar',
+        50
+      )
+    );
   });
 
   test('timer object double call', () => {
@@ -285,10 +320,15 @@
 
   test('reportExtension', () => {
     service.reportExtension('foo');
-    assert.isTrue(service.reporter.calledWithExactly(
-        'lifecycle', 'Extension detected', 'Extension detected', undefined,
+    assert.isTrue(
+      service.reporter.calledWithExactly(
+        'lifecycle',
+        'Extension detected',
+        'Extension detected',
+        undefined,
         {name: 'foo'}
-    ));
+      )
+    );
   });
 
   test('reportInteraction', () => {
@@ -296,13 +336,13 @@
     sinon.spy(service, '_reportEvent');
     service.pluginsLoaded(); // so we don't cache
     service.reportInteraction('button-click', {name: 'sendReply'});
-    assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
-        {
-          type: 'interaction',
-          name: 'button-click',
-          eventDetails: JSON.stringify({name: 'sendReply'}),
-        }
-    ));
+    assert.isTrue(
+      service._reportEvent.getCall(2).calledWithMatch({
+        type: 'interaction',
+        name: 'button-click',
+        eventDetails: JSON.stringify({name: 'sendReply'}),
+      })
+    );
   });
 
   test('trackApi reports same event only once', () => {
@@ -323,16 +363,19 @@
     service.pluginsLoaded();
     service.time('timeAction');
     service.timeEnd('timeAction');
-    assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
-        {
-          type: 'timing-report',
-          category: 'UI Latency',
-          name: 'timeAction',
-          value: 0,
-          eventStart: 42,
-        }
-    ));
-    assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
+    assert.isTrue(
+      service._reportEvent.getCall(2).calledWithMatch({
+        type: 'timing-report',
+        category: 'UI Latency',
+        name: 'timeAction',
+        value: 0,
+        eventStart: 42,
+      })
+    );
+    assert.equal(
+      (dispatchStub.getCall(2).args[0] as CustomEvent).detail.eventStart,
+      42
+    );
   });
 
   test('dedup', () => {
@@ -382,25 +425,25 @@
     test('pluginsLoaded reports time', () => {
       sinon.stub(window.performance, 'now').returns(42);
       service.pluginsLoaded();
-      assert.isTrue(service._reportEvent.calledWithMatch(
-          {
-            type: 'timing-report',
-            category: 'UI Latency',
-            name: 'PluginsLoaded',
-            value: 42,
-          }
-      ));
+      assert.isTrue(
+        service._reportEvent.calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'PluginsLoaded',
+          value: 42,
+        })
+      );
     });
 
     test('pluginsLoaded reports plugins', () => {
       service.pluginsLoaded(['foo', 'bar']);
-      assert.isTrue(service._reportEvent.calledWithMatch(
-          {
-            type: 'lifecycle',
-            category: 'Plugins installed',
-            eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
-          }
-      ));
+      assert.isTrue(
+        service._reportEvent.calledWithMatch({
+          type: 'lifecycle',
+          category: 'Plugins installed',
+          eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
+        })
+      );
     });
 
     test('caches reports if plugins are not loaded', () => {
@@ -424,33 +467,58 @@
       service.timeEnd('foo');
       service.pluginsLoaded();
       service.timeEnd('bar');
-      assert.isTrue(service._reportEvent.getCall(0).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency', name: 'foo'}
-      ));
-      assert.isTrue(service._reportEvent.getCall(1).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency',
-            name: 'PluginsLoaded'}
-      ));
-      assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
-          {type: 'lifecycle', category: 'Plugins installed'}
-      ));
-      assert.isTrue(service._reportEvent.getCall(3).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency', name: 'bar'}
-      ));
+      assert.isTrue(
+        service._reportEvent.getCall(0).calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'foo',
+        })
+      );
+      assert.isTrue(
+        service._reportEvent.getCall(1).calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'PluginsLoaded',
+        })
+      );
+      assert.isTrue(
+        service._reportEvent
+          .getCall(2)
+          .calledWithMatch({type: 'lifecycle', category: 'Plugins installed'})
+      );
+      assert.isTrue(
+        service._reportEvent.getCall(3).calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'bar',
+        })
+      );
     });
   });
 
   test('search', () => {
     service.locationChanged('_handleSomeRoute');
-    assert.isTrue(service.reporter.calledWithExactly(
-        'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
+    assert.isTrue(
+      service.reporter.calledWithExactly(
+        'nav-report',
+        'Location Changed',
+        'Page',
+        '_handleSomeRoute'
+      )
+    );
   });
 
   suite('exception logging', () => {
-    let fakeWindow;
-    let reporter;
+    let fakeWindow: any;
+    let reporter: sinon.SinonStub;
 
-    const emulateThrow = function(msg, url, line, column, error) {
+    const emulateThrow = function (
+      msg?: string,
+      url?: string,
+      line?: number,
+      column?: number,
+      error?: Error
+    ) {
       return fakeWindow.onerror(msg, url, line, column, error);
     };
 
@@ -458,7 +526,7 @@
       reporter = service.reporter;
       fakeWindow = {
         handlers: {},
-        addEventListener(type, handler) {
+        addEventListener(type: string, handler: object) {
           this.handlers[type] = handler;
         },
       };
@@ -493,9 +561,9 @@
     test('unhandled rejection', () => {
       const newError = new Error('bar');
       fakeWindow.handlers['unhandledrejection']({reason: newError});
-      assert.isTrue(reporter.calledWith('error', 'exception',
-          'unhandledrejection: bar'));
+      assert.isTrue(
+        reporter.calledWith('error', 'exception', 'unhandledrejection: bar')
+      );
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
similarity index 95%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
rename to polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index bcb2629..3193833 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -16,8 +16,7 @@
  */
 /* NB: Order is important, because of namespaced classes. */
 
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {GrEtagDecorator} from './gr-etag-decorator';
+import {GrEtagDecorator} from '../../elements/shared/gr-rest-api-interface/gr-etag-decorator';
 import {
   FetchJSONRequest,
   FetchParams,
@@ -28,20 +27,19 @@
   SendJSONRequest,
   SendRequest,
   SiteBasedCache,
-} from './gr-rest-apis/gr-rest-api-helper';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser';
-import {parseDate} from '../../../utils/date-util';
-import {getBaseUrl} from '../../../utils/url-util';
-import {getAppContext} from '../../../services/app-context';
-import {Finalizable} from '../../../services/registry';
-import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {parseDate} from '../../utils/date-util';
+import {getBaseUrl} from '../../utils/url-util';
+import {getAppContext} from '../app-context';
+import {Finalizable} from '../registry';
+import {getParentIndex, isMergeParent} from '../../utils/patch-set-util';
 import {
   ListChangesOption,
   listChangesOptionsToHex,
-} from '../../../utils/change-util';
-import {assertNever, hasOwnProperty} from '../../../utils/common-util';
-import {customElement} from '@polymer/decorators';
-import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth';
+} from '../../utils/change-util';
+import {assertNever, hasOwnProperty} from '../../utils/common-util';
+import {AuthRequestInit, AuthService} from '../gr-auth/gr-auth';
 import {
   AccountCapabilityInfo,
   AccountDetailInfo,
@@ -132,18 +130,18 @@
   TagInput,
   TopMenuEntryInfo,
   UrlEncodedCommentId,
-} from '../../../types/common';
+} from '../../types/common';
 import {
   DiffInfo,
   DiffPreferencesInfo,
   IgnoreWhitespaceType,
-} from '../../../types/diff';
+} from '../../types/diff';
 import {
   CancelConditionCallback,
   GetDiffCommentsOutput,
   GetDiffRobotCommentsOutput,
   RestApiService,
-} from '../../../services/gr-rest-api/gr-rest-api';
+} from './gr-rest-api';
 import {
   CommentSide,
   createDefaultDiffPrefs,
@@ -151,13 +149,13 @@
   createDefaultPreferences,
   HttpMethod,
   ReviewerState,
-} from '../../../constants/constants';
-import {firePageError, fireServerError} from '../../../utils/event-util';
-import {ParsedChangeInfo} from '../../../types/types';
-import {ErrorCallback} from '../../../api/rest';
-import {addDraftProp, DraftInfo} from '../../../utils/comment-util';
-import {BaseScheduler} from '../../../services/scheduler/scheduler';
-import {MaxInFlightScheduler} from '../../../services/scheduler/max-in-flight-scheduler';
+} from '../../constants/constants';
+import {firePageError, fireServerError} from '../../utils/event-util';
+import {ParsedChangeInfo} from '../../types/types';
+import {ErrorCallback} from '../../api/rest';
+import {addDraftProp, DraftInfo} from '../../utils/comment-util';
+import {BaseScheduler} from '../scheduler/scheduler';
+import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
 
 const MAX_PROJECT_RESULTS = 25;
 
@@ -275,12 +273,6 @@
   getAppContext().authService.clearCache();
 }
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-rest-api-service-impl': GrRestApiServiceImpl;
-  }
-}
-
 function createReadScheduler() {
   return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10);
 }
@@ -288,12 +280,7 @@
 function createWriteScheduler() {
   return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
 }
-
-@customElement('gr-rest-api-service-impl')
-export class GrRestApiServiceImpl
-  extends PolymerElement
-  implements RestApiService, Finalizable
-{
+export class GrRestApiServiceImpl implements RestApiService, Finalizable {
   readonly _cache = siteBasedCache; // Shared across instances.
 
   readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
@@ -311,7 +298,6 @@
   private readonly _restApiHelper: GrRestApiHelper;
 
   constructor(authService?: AuthService) {
-    super();
     // TODO: Make the authService constructor parameter required when we have
     // changed all usages of this class to not instantiate via createElement().
     this.authService = authService ?? getAppContext().authService;
@@ -1028,34 +1014,13 @@
     });
   }
 
-  getChanges(
+  getRequestForGetChanges(
     changesPerPage?: number,
-    query?: string,
+    query?: string[] | string,
     offset?: 'n,z' | number,
     options?: string
-  ): Promise<ChangeInfo[] | undefined>;
-
-  getChanges(
-    changesPerPage?: number,
-    query?: string[],
-    offset?: 'n,z' | number,
-    options?: string
-  ): Promise<ChangeInfo[][] | undefined>;
-
-  /**
-   * @return If opt_query is an
-   * array, _fetchJSON will return an array of arrays of changeInfos. If it
-   * is unspecified or a string, _fetchJSON will return an array of
-   * changeInfos.
-   */
-  getChanges(
-    changesPerPage?: number,
-    query?: string | string[],
-    offset?: 'n,z' | number,
-    options?: string
-  ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
+  ) {
     options = options || this._getChangesOptionsHex();
-    // Issue 4524: respect legacy token with max sortkey.
     if (offset === 'n,z') {
       offset = 0;
     }
@@ -1074,6 +1039,23 @@
       params,
       reportUrlAsIs: true,
     };
+    return request;
+  }
+
+  getChangesForMultipleQueries(
+    changesPerPage?: number,
+    query?: string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[][] | undefined> {
+    if (!query) return Promise.resolve(undefined);
+
+    const request = this.getRequestForGetChanges(
+      changesPerPage,
+      query,
+      offset,
+      options
+    );
 
     return Promise.resolve(
       this._restApiHelper.fetchJSON(request, true) as Promise<
@@ -1088,26 +1070,68 @@
           this._maybeInsertInLookup(change);
         }
       };
-      // Response may be an array of changes OR an array of arrays of
-      // changes.
-      if (query instanceof Array) {
-        // Normalize the response to look like a multi-query response
-        // when there is only one query.
-        const responseArray: Array<ChangeInfo[]> =
-          query.length === 1
-            ? [response as ChangeInfo[]]
-            : (response as ChangeInfo[][]);
-        for (const arr of responseArray) {
-          iterateOverChanges(arr);
-        }
-        return responseArray;
-      } else {
-        iterateOverChanges(response as ChangeInfo[]);
-        return response as ChangeInfo[];
+      // Normalize the response to look like a multi-query response
+      // when there is only one query.
+      const responseArray: Array<ChangeInfo[]> =
+        query.length === 1
+          ? [response as ChangeInfo[]]
+          : (response as ChangeInfo[][]);
+      for (const arr of responseArray) {
+        iterateOverChanges(arr);
       }
+      return responseArray;
     });
   }
 
+  getChanges(
+    changesPerPage?: number,
+    query?: string,
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[] | undefined> {
+    const request = this.getRequestForGetChanges(
+      changesPerPage,
+      query,
+      offset,
+      options
+    );
+
+    return Promise.resolve(
+      this._restApiHelper.fetchJSON(request, true) as Promise<
+        ChangeInfo[] | undefined
+      >
+    ).then(response => {
+      if (!response) {
+        return;
+      }
+      const iterateOverChanges = (arr: ChangeInfo[]) => {
+        for (const change of arr) {
+          this._maybeInsertInLookup(change);
+        }
+      };
+      iterateOverChanges(response);
+      return response;
+    });
+  }
+
+  async getDetailedChangesWithActions(changeNums: NumericChangeId[]) {
+    const query = changeNums.map(num => `change:${num}`).join(' OR ');
+    const changeDetails = await this.getChanges(
+      undefined,
+      query,
+      undefined,
+      listChangesOptionsToHex(
+        ListChangesOption.CHANGE_ACTIONS,
+        ListChangesOption.CURRENT_ACTIONS,
+        ListChangesOption.CURRENT_REVISION,
+        ListChangesOption.DETAILED_LABELS,
+        // TODO: remove this option and merge requirements from dashbaord req
+        ListChangesOption.SUBMIT_REQUIREMENTS
+      )
+    );
+    return changeDetails;
+  }
+
   /**
    * Inserts a change into _projectLookup iff it has a valid structure.
    */
@@ -1793,7 +1817,7 @@
   }
 
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
-    const query = [`intopic:"${topic}"`].join(' ');
+    const query = `intopic:"${topic}"`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
@@ -1801,6 +1825,17 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `inhashtag:"${hashtag}"`;
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/inhashtag:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
   getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
@@ -2511,7 +2546,7 @@
   }
 
   /**
-   * @returns Whether there are pending diff draft sends.
+   * @return Whether there are pending diff draft sends.
    */
   hasPendingDiffDrafts(): number {
     const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
@@ -2519,7 +2554,7 @@
   }
 
   /**
-   * @returns A promise that resolves when all pending
+   * @return A promise that resolves when all pending
    * diff draft sends have resolved.
    */
   awaitPendingDiffDrafts(): Promise<void> {
@@ -2667,20 +2702,22 @@
 
     return Promise.all([promiseA, promiseB]).then(results => {
       // Sometimes the server doesn't send back the content type.
-      const baseImage: Base64ImageFile | null = results[0]
-        ? {
-            ...results[0],
-            _expectedType: diff.meta_a.content_type,
-            _name: diff.meta_a.name,
-          }
-        : null;
-      const revisionImage: Base64ImageFile | null = results[1]
-        ? {
-            ...results[1],
-            _expectedType: diff.meta_b.content_type,
-            _name: diff.meta_b.name,
-          }
-        : null;
+      const baseImage: Base64ImageFile | null =
+        results[0] && diff.meta_a
+          ? {
+              ...results[0],
+              _expectedType: diff.meta_a.content_type,
+              _name: diff.meta_a.name,
+            }
+          : null;
+      const revisionImage: Base64ImageFile | null =
+        results[1] && diff.meta_b
+          ? {
+              ...results[1],
+              _expectedType: diff.meta_b.content_type,
+              _name: diff.meta_b.name,
+            }
+          : null;
       const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
       return imagesForDiff;
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
similarity index 96%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
rename to polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
index 1773353..fbfd152 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
@@ -15,20 +15,25 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import {addListenerForTest, mockPromise, stubAuth} from '../../../test/test-utils.js';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {ListChangesOption} from '../../../utils/change-util.js';
-import {getAppContext} from '../../../services/app-context.js';
-import {createChange} from '../../../test/test-data-generators.js';
-import {CURRENT} from '../../../utils/patch-set-util.js';
-import {
-  parsePrefixedJSON,
-  readResponsePayload,
-} from './gr-rest-apis/gr-rest-api-helper.js';
-import {JSON_PREFIX} from './gr-rest-apis/gr-rest-api-helper.js';
+import '../../test/common-test-setup-karma.js';
+import {addListenerForTest, mockPromise, stubAuth} from '../../test/test-utils.js';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js';
+import {ListChangesOption, listChangesOptionsToHex} from '../../utils/change-util.js';
+import {getAppContext} from '../app-context.js';
+import {createChange} from '../../test/test-data-generators.js';
+import {CURRENT} from '../../utils/patch-set-util.js';
+import {parsePrefixedJSON, readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
+import {JSON_PREFIX} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
 import {GrRestApiServiceImpl} from './gr-rest-api-impl.js';
 
+const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
+    ListChangesOption.CHANGE_ACTIONS,
+    ListChangesOption.CURRENT_ACTIONS,
+    ListChangesOption.CURRENT_REVISION,
+    ListChangesOption.DETAILED_LABELS,
+    ListChangesOption.SUBMIT_REQUIREMENTS
+);
+
 suite('gr-rest-api-service-impl tests', () => {
   let element;
 
@@ -1020,7 +1025,7 @@
           ]));
       // When opt_query instanceof Array, _fetchJSON returns
       // Array<Array<Object>>.
-      await element.getChanges(null, []);
+      await element.getChangesForMultipleQueries(null, []);
       assert.equal(Object.keys(element._projectLookup).length, 3);
       const project1 = await element.getFromProjectLookup(1);
       assert.equal(project1, 'test');
@@ -1051,6 +1056,24 @@
     });
   });
 
+  test('getDetailedChangesWithActions', async () => {
+    const c1 = createChange();
+    c1._number = 1;
+    const c2 = createChange();
+    c2._number = 2;
+    const getChangesStub = sinon.stub(element, 'getChanges').callsFake(
+        (changesPerPage, query, offset, options) => {
+          assert.isUndefined(changesPerPage);
+          assert.strictEqual(query, 'change:1 OR change:2');
+          assert.isUndefined(offset);
+          assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
+          return Promise.resolve([]);
+        }
+    );
+    await element.getDetailedChangesWithActions([c1._number, c2._number]);
+    assert.isTrue(getChangesStub.calledOnce);
+  });
+
   test('_getChangeURLAndFetch', () => {
     element._projectLookup = {1: Promise.resolve('test')};
     const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON')
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 7c35222..0ea561f 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -451,30 +451,22 @@
     errFn?: ErrorCallback
   ): Promise<{[pluginName: string]: PluginInfo} | undefined>;
 
+  getDetailedChangesWithActions(
+    changeNums: NumericChangeId[]
+  ): Promise<ChangeInfo[] | undefined>;
+
   getChanges(
     changesPerPage?: number,
     query?: string,
     offset?: 'n,z' | number,
     options?: string
   ): Promise<ChangeInfo[] | undefined>;
-  getChanges(
+  getChangesForMultipleQueries(
     changesPerPage?: number,
     query?: string[],
     offset?: 'n,z' | number,
     options?: string
   ): Promise<ChangeInfo[][] | undefined>;
-  /**
-   * @return If opt_query is an
-   * array, _fetchJSON will return an array of arrays of changeInfos. If it
-   * is unspecified or a string, _fetchJSON will return an array of
-   * changeInfos.
-   */
-  getChanges(
-    changesPerPage?: number,
-    query?: string | string[],
-    offset?: 'n,z' | number,
-    options?: string
-  ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined>;
 
   getDocumentationSearches(filter: string): Promise<DocResult[] | undefined>;
 
@@ -642,6 +634,9 @@
     }
   ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined>;
 
   hasPendingDiffDrafts(): number;
   awaitPendingDiffDrafts(): Promise<void>;
diff --git a/polygerrit-ui/app/services/highlight/highlight-service-mock.ts b/polygerrit-ui/app/services/highlight/highlight-service-mock.ts
new file mode 100644
index 0000000..f65fbf5
--- /dev/null
+++ b/polygerrit-ui/app/services/highlight/highlight-service-mock.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  SyntaxWorkerMessage,
+  SyntaxWorkerResult,
+} from '../../types/syntax-worker-api';
+import {HighlightService} from './highlight-service';
+
+class FakeWorker implements Worker {
+  messages: SyntaxWorkerMessage[] = [];
+
+  constructor(private readonly autoRespond = true) {}
+
+  postMessage(message: SyntaxWorkerMessage) {
+    this.messages.push(message);
+    if (this.autoRespond) this.sendResult({ranges: []});
+  }
+
+  sendResult(result: SyntaxWorkerResult) {
+    if (this.onmessage)
+      this.onmessage({data: result} as MessageEvent<SyntaxWorkerResult>);
+  }
+
+  onmessage: ((e: MessageEvent<SyntaxWorkerResult>) => void) | null = null;
+
+  onmessageerror = null;
+
+  onerror = null;
+
+  terminate(): void {}
+
+  addEventListener(): void {}
+
+  removeEventListener(): void {}
+
+  dispatchEvent(): boolean {
+    return true;
+  }
+}
+
+export class MockHighlightService extends HighlightService {
+  idle = this.poolIdle as Set<FakeWorker>;
+
+  busy = this.poolBusy as Set<FakeWorker>;
+
+  override createWorker(): Worker {
+    return new FakeWorker();
+  }
+
+  countAllMessages() {
+    let count = 0;
+    for (const worker of [...this.idle, ...this.busy]) {
+      count += worker.messages.length;
+    }
+    return count;
+  }
+
+  sendToAll(result: SyntaxWorkerResult) {
+    for (const worker of this.busy) {
+      worker.sendResult(result);
+    }
+  }
+}
+
+export class MockHighlightServiceManual extends MockHighlightService {
+  override createWorker(): Worker {
+    return new FakeWorker(false);
+  }
+}
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
index 2711ba5..80da260 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -10,12 +10,18 @@
   SyntaxWorkerMessageType,
   SyntaxLayerLine,
 } from '../../types/syntax-worker-api';
+import {prependOrigin} from '../../utils/url-util';
 import {createWorker} from '../../utils/worker-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
 import {Finalizable} from '../registry';
 
-const hljsLibUrl = `${window.STATIC_RESOURCE_PATH}/bower_components/highlightjs/highlight.min.js`;
+const hljsLibUrl = `${
+  window.STATIC_RESOURCE_PATH ?? ''
+}/bower_components/highlightjs/highlight.min.js`;
 
-const syntaxWorkerUrl = `${window.STATIC_RESOURCE_PATH}/workers/syntax-worker.js`;
+const syntaxWorkerUrl = `${
+  window.STATIC_RESOURCE_PATH ?? ''
+}/workers/syntax-worker.js`;
 
 /**
  * It is unlikely that a pool size greater than 3 will gain anything, because
@@ -24,6 +30,16 @@
 const WORKER_POOL_SIZE = 3;
 
 /**
+ * Safe guard for not killing the browser.
+ */
+export const CODE_MAX_LINES = 20 * 1000;
+
+/**
+ * Safe guard for not killing the browser. Maximum in number of chars.
+ */
+const CODE_MAX_LENGTH = 25 * CODE_MAX_LINES;
+
+/**
  * Service for syntax highlighting. Maintains some HighlightJS workers doing
  * their job in the background.
  */
@@ -42,7 +58,7 @@
   /** Queue for waiting on the results of a worker. */
   queueForResult: Map<Worker, (r: SyntaxLayerLine[]) => void> = new Map();
 
-  constructor() {
+  constructor(readonly reporting: ReportingService) {
     for (let i = 0; i < WORKER_POOL_SIZE; i++) {
       this.addWorker();
     }
@@ -50,7 +66,7 @@
 
   /** Allows tests to produce fake workers. */
   protected createWorker() {
-    return createWorker(syntaxWorkerUrl);
+    return createWorker(prependOrigin(syntaxWorkerUrl));
   }
 
   /** Creates, initializes and then moves a worker to the idle pool. */
@@ -63,7 +79,7 @@
     };
     const initMsg: SyntaxWorkerInit = {
       type: SyntaxWorkerMessageType.INIT,
-      url: hljsLibUrl,
+      url: prependOrigin(hljsLibUrl),
     };
     worker.postMessage(initMsg);
   }
@@ -101,13 +117,20 @@
    */
   private handleResult(worker: Worker, result: SyntaxWorkerResult) {
     this.moveBusyToIdle(worker);
-    if (result.error) console.error(`syntax worker failed: ${result.error}`);
+    if (result.error) {
+      this.reporting.error(new Error(`syntax worker failed: ${result.error}`));
+    }
     const resolver = this.queueForResult.get(worker);
     this.queueForResult.delete(worker);
     if (resolver) resolver(result.ranges ?? []);
   }
 
-  async highlight(language: string, code: string): Promise<SyntaxLayerLine[]> {
+  async highlight(
+    language?: string,
+    code?: string
+  ): Promise<SyntaxLayerLine[]> {
+    if (!language || !code) return [];
+    if (code.length > CODE_MAX_LENGTH) return [];
     const worker = await this.requestWorker();
     const message: SyntaxWorkerRequest = {
       type: SyntaxWorkerMessageType.REQUEST,
diff --git a/polygerrit-ui/app/services/highlight/highlight-service_test.ts b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
index dc0c2f7..4c38a68 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service_test.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
@@ -5,79 +5,14 @@
  */
 import '../../test/common-test-setup-karma';
 import {waitUntil} from '../../test/test-utils';
-import {
-  SyntaxWorkerMessage,
-  SyntaxWorkerResult,
-} from '../../types/syntax-worker-api';
-import {HighlightService} from './highlight-service';
-
-class FakeWorker implements Worker {
-  messages: SyntaxWorkerMessage[] = [];
-
-  constructor(private readonly autoRespond = true) {}
-
-  postMessage(message: SyntaxWorkerMessage) {
-    this.messages.push(message);
-    if (this.autoRespond) this.sendResult({ranges: []});
-  }
-
-  sendResult(result: SyntaxWorkerResult) {
-    if (this.onmessage)
-      this.onmessage({data: result} as MessageEvent<SyntaxWorkerResult>);
-  }
-
-  onmessage: ((e: MessageEvent<SyntaxWorkerResult>) => any) | null = null;
-
-  onmessageerror = null;
-
-  onerror = null;
-
-  terminate(): void {}
-
-  addEventListener(): void {}
-
-  removeEventListener(): void {}
-
-  dispatchEvent(): boolean {
-    return true;
-  }
-}
-
-export class MockHighlightService extends HighlightService {
-  idle = this.poolIdle as Set<FakeWorker>;
-
-  busy = this.poolBusy as Set<FakeWorker>;
-
-  override createWorker(): Worker {
-    return new FakeWorker();
-  }
-
-  countAllMessages() {
-    let count = 0;
-    for (const worker of [...this.idle, ...this.busy]) {
-      count += worker.messages.length;
-    }
-    return count;
-  }
-
-  sendToAll(result: SyntaxWorkerResult) {
-    for (const worker of this.busy) {
-      worker.sendResult(result);
-    }
-  }
-}
-
-export class MockHighlightServiceManual extends MockHighlightService {
-  override createWorker(): Worker {
-    return new FakeWorker(false);
-  }
-}
+import {grReportingMock} from '../gr-reporting/gr-reporting_mock';
+import {MockHighlightServiceManual} from './highlight-service-mock';
 
 suite('highlight-service tests', () => {
   let service: MockHighlightServiceManual;
 
   setup(() => {
-    service = new MockHighlightServiceManual();
+    service = new MockHighlightServiceManual(grReportingMock);
   });
 
   test('initial state', () => {
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index e7de1ef..74b6997 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -73,7 +73,7 @@
             initializing = true;
             initialized.set(name, factory(context));
           } catch (e) {
-            console.error(`Failed to initialize ${name}`, e);
+            console.error(`Failed to initialize ${String(name)}`, e);
           } finally {
             initializing = false;
           }
diff --git a/polygerrit-ui/app/services/registry_test.ts b/polygerrit-ui/app/services/registry_test.ts
index d677be0..3bb584a 100644
--- a/polygerrit-ui/app/services/registry_test.ts
+++ b/polygerrit-ui/app/services/registry_test.ts
@@ -48,9 +48,8 @@
       foo: (_ctx: Partial<DemoContext>) => new Foo(final),
       bar: (ctx: Partial<DemoContext>) => new Bar(final, ctx.foo),
     };
-    const demoContext: DemoContext & Finalizable = create<DemoContext>(
-      demoRegistry
-    ) as DemoContext & Finalizable;
+    const demoContext: DemoContext & Finalizable =
+      create<DemoContext>(demoRegistry);
     demoContext.finalize();
     assert.deepEqual(final, ['Foo', 'Bar']);
   });
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 41591a2..de28a60 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -355,9 +355,7 @@
   describeBindings(shortcut: Shortcut): string[][] | null {
     const bindings = this.bindings.get(shortcut);
     if (!bindings) return null;
-    return bindings
-      .filter(binding => !binding.docOnly)
-      .map(binding => describeBinding(binding));
+    return bindings.map(binding => describeBinding(binding));
   }
 
   notifyViewListeners() {
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
index 67a6963..aa16473 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -18,40 +18,39 @@
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+import {css} from 'lit';
+
+export const changeMetadataStyles = css`
+  section {
+    display: table-row;
+  }
+
+  section:not(:first-of-type) .title,
+  section:not(:first-of-type) .value {
+    padding-top: var(--spacing-s);
+  }
+
+  .title,
+  .value {
+    display: table-cell;
+    vertical-align: top;
+  }
+
+  .title {
+    color: var(--deemphasized-text-color);
+    max-width: 20em;
+    padding-left: var(--metadata-horizontal-padding);
+    padding-right: var(--metadata-horizontal-padding);
+    word-break: break-word;
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
 $_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-shared-styles">
   <template>
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
     <style>
-      section {
-        display: table-row;
-      }
-
-      section:not(:first-of-type) .title,
-      section:not(:first-of-type) .value {
-        padding-top: var(--spacing-s);
-      }
-
-      .title,
-      .value {
-        display: table-cell;
-        vertical-align: top;
-      }
-
-      .title {
-        color: var(--deemphasized-text-color);
-        max-width: 20em;
-        padding-left: var(--metadata-horizontal-padding);
-        padding-right: var(--metadata-horizontal-padding);
-        word-break: break-word;
-      }
+    ${changeMetadataStyles.cssText}
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-font-styles.ts b/polygerrit-ui/app/styles/gr-font-styles.ts
index 422a7c5..670b576 100644
--- a/polygerrit-ui/app/styles/gr-font-styles.ts
+++ b/polygerrit-ui/app/styles/gr-font-styles.ts
@@ -45,6 +45,12 @@
     font-weight: var(--font-weight-h3);
     line-height: var(--line-height-h3);
   }
+  .heading-4 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-normal);
+    font-weight: var(--font-weight-h4);
+    line-height: var(--line-height-normal);
+  }
   strong {
     font-weight: var(--font-weight-bold);
   }
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index e99cf27..a83e897 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -138,6 +138,8 @@
       box-sizing: border-box;
       padding: var(--spacing-s);
     }
+    --iron-autogrow-textarea_-_box-sizing: border-box;
+    --iron-autogrow-textarea_-_padding: var(--spacing-s);
   }
   a {
     color: var(--link-color);
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 18c6b5d..dc17ee6 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -3,9 +3,9 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {render, css} from 'lit';
+import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util';
 
-const appThemeCss = css`
+const appThemeCss = safeStyleSheet`
   html {
     /**
        * When adding a new color variable make sure to also add it to the other
@@ -46,6 +46,7 @@
     --blue-50: #e8f0fe;
     --blue-tonal: #314972;
     --orange-900: #b06000;
+    --orange-800: #c26401;
     --orange-700: #d56e0c;
     --orange-700-04: #d56e0c0a;
     --orange-700-10: #d56e0c1a;
@@ -285,6 +286,7 @@
 
     /* misc colors */
     --border-color: var(--gray-300);
+    --input-focus-border-color: var(--blue-800);
     --comment-separator-color: var(--gray-300);
 
     /* checks tag colors */
@@ -337,7 +339,8 @@
     --font-weight-bold: 500;
     --font-weight-h1: 400;
     --font-weight-h2: 400;
-    --font-weight-h3: var(--font-weight-bold, 500);
+    --font-weight-h3: 400;
+    --font-weight-h4: 600;
     --context-control-button-font: var(--font-weight-normal)
       var(--font-size-normal) var(--font-family);
     --code-hint-font-weight: 500;
@@ -381,6 +384,7 @@
     --diff-selection-background-color: #c7dbf9;
     --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);
@@ -398,9 +402,15 @@
     --syntax-attr-color: #219;
     --syntax-attribute-color: var(--primary-text-color);
     --syntax-built_in-color: #30a;
+    --syntax-bullet-color: var(--syntax-keyword-color);
+    --syntax-code-color: var(--syntax-literal-color);
     --syntax-comment-color: #3f7f5f;
     --syntax-default-color: var(--primary-text-color);
     --syntax-doctag-weight: bold;
+    --syntax-emphasis-color: var(--primary-text-color);
+    --syntax-emphasis-style: italic;
+    --syntax-emphasis-weight: normal;
+    --syntax-formula-color: var(--syntax-regexp-color);
     --syntax-function-color: var(--primary-text-color);
     --syntax-keyword-color: #9e0069;
     --syntax-link-color: #219;
@@ -409,19 +419,28 @@
     --syntax-meta-keyword-color: #219;
     --syntax-number-color: #164;
     --syntax-params-color: var(--primary-text-color);
+    --syntax-property-color: var(--primary-text-color);
+    --syntax-quote-color: var(--primary-text-color);
     --syntax-regexp-color: #fa8602;
+    --syntax-section-color: var(--syntax-keyword-color);
+    --syntax-section-style: normal;
+    --syntax-section-weight: bold;
     --syntax-selector-attr-color: #fa8602;
     --syntax-selector-class-color: #164;
     --syntax-selector-id-color: #2a00ff;
-    --syntax-property-color: #fa8602;
     --syntax-selector-pseudo-color: #fa8602;
     --syntax-string-color: #2a00ff;
+    --syntax-strong-color: var(--primary-text-color);
+    --syntax-strong-style: normal;
+    --syntax-strong-weight: bold;
     --syntax-tag-color: #170;
     --syntax-template-tag-color: #fa8602;
     --syntax-template-variable-color: #0000c0;
     --syntax-title-color: #0000c0;
+    --syntax-title-function-color: var(--syntax-title-color);
     --syntax-type-color: var(--blue-700);
     --syntax-variable-color: var(--primary-text-color);
+    --syntax-variable-language-color: var(--syntax-built_in-color);
 
     /* elevation */
     --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, 0.3),
@@ -467,13 +486,13 @@
 
 const styleEl = document.createElement('style');
 styleEl.setAttribute('id', 'light-theme');
-render(appThemeCss, styleEl);
+safeStyleEl.setTextContent(styleEl, appThemeCss);
 document.head.appendChild(styleEl);
 
 // TODO: The following can be removed when Paper and Iron components have been
 // removed from Gerrit.
 
-const appThemeCssPolymerLegacy = css`
+const appThemeCssPolymerLegacy = safeStyleSheet`
   html {
     --paper-tooltip: {
       font-size: var(--font-size-small);
@@ -486,6 +505,6 @@
 
 const customStyleEl = document.createElement('custom-style');
 const innerStyleEl = document.createElement('style');
-render(appThemeCssPolymerLegacy, innerStyleEl);
+safeStyleEl.setTextContent(innerStyleEl, appThemeCssPolymerLegacy);
 customStyleEl.appendChild(innerStyleEl);
 document.head.appendChild(customStyleEl);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index e41054a..6f19924 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -3,13 +3,13 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {render, css} from 'lit';
+import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util';
 
 // TODO: Replace `html` with `html.darkTheme`. But before we can do that we have
 // to ensure that all plugins also use `.darkTheme`, otherwise we would trump
 // their sepcificity here. When we do that we can also always execute
 // applyTheme() below (similar to app-theme).
-const darkThemeCss = css`
+const darkThemeCss = safeStyleSheet`
   html {
     /**
        * Sections and variables must stay consistent with app-theme.js.
@@ -155,6 +155,7 @@
 
     /* misc colors */
     --border-color: var(--gray-700);
+    --input-focus-border-color: var(--blue-200);
     --comment-separator-color: var(--border-color);
 
     /* checks tag colors */
@@ -218,6 +219,7 @@
     --diff-selection-background-color: #3a71d8;
     --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;
@@ -246,19 +248,21 @@
     --syntax-meta-keyword-color: #eefff7;
     --syntax-number-color: #00998a;
     --syntax-params-color: var(--primary-text-color);
+    --syntax-property-color: #c792ea;
     --syntax-regexp-color: #f77669;
     --syntax-selector-attr-color: #80cbbf;
     --syntax-selector-class-color: #ffcb68;
     --syntax-selector-id-color: #f77669;
     --syntax-selector-pseudo-color: #c792ea;
-    --syntax-property-color: #c792ea;
     --syntax-string-color: #c3e88d;
     --syntax-tag-color: #f77669;
     --syntax-template-tag-color: #c792ea;
     --syntax-template-variable-color: #f77669;
     --syntax-title-color: #75a5ff;
+    --syntax-title-function-color: var(--syntax-title-color);
     --syntax-type-color: #dd5f5f;
     --syntax-variable-color: #f77669;
+    --syntax-variable-language-color: var(--syntax-built_in-color);
 
     /* misc */
     --line-length-indicator-color: #d7aefb;
@@ -274,6 +278,6 @@
 export function applyTheme() {
   const styleEl = document.createElement('style');
   styleEl.setAttribute('id', 'dark-theme');
-  render(darkThemeCss, styleEl);
+  safeStyleEl.setTextContent(styleEl, darkThemeCss);
   document.head.appendChild(styleEl);
 }
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index a567bf3a..46ba4ab 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -29,7 +29,7 @@
   Creator,
 } from './test-app-context-init';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
+import {_testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
 import {
   cleanupTestUtils,
   getCleanupsCount,
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
deleted file mode 100644
index fc4599d..0000000
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-
-/**
- * This is an "abstract" class for tests. The descendant must define a template
- * for this element and a tagName - see createCommentApiMockWithTemplateElement below
- */
-class CommentApiMock extends LegacyElementMixin(PolymerElement) {
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-}
-
-/**
- * Creates a new element which is descendant of CommentApiMock with specified
- * template. Additionally, the method registers a tagName for this element.
- *
- * Each tagName must be a unique accross all tests.
- */
-export function createCommentApiMockWithTemplateElement(tagName, template) {
-  const elementClass = class extends CommentApiMock {
-    static get is() { return tagName; }
-
-    static get template() { return template; }
-  };
-  customElements.define(tagName, elementClass);
-  return elementClass;
-}
diff --git a/polygerrit-ui/app/test/mocks/diff-response.js b/polygerrit-ui/app/test/mocks/diff-response.js
deleted file mode 100644
index 8ca44c2..0000000
--- a/polygerrit-ui/app/test/mocks/diff-response.js
+++ /dev/null
@@ -1,149 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-export function getMockDiffResponse() {
-  // Return new response, so tests can't affect each other - if a test somehow
-  // modifies it, the future calls return original value
-  // Do not put it to a const outside of a method
-  return {
-    meta_a: {
-      name: 'lorem-ipsum.txt',
-      content_type: 'text/plain',
-      lines: 45,
-    },
-    meta_b: {
-      name: 'lorem-ipsum.txt',
-      content_type: 'text/plain',
-      lines: 48,
-    },
-    intraline_status: 'OK',
-    change_type: 'MODIFIED',
-    diff_header: [
-      'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-      'index b2adcf4..554ae49 100644',
-      '--- a/lorem-ipsum.txt',
-      '+++ b/lorem-ipsum.txt',
-    ],
-    content: [
-      {
-        ab: [
-          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
-          'nulla phasellus.',
-          'Mattis lectus.',
-          'Sodales duis.',
-          'Orci a faucibus.',
-        ],
-      },
-      {
-        b: [
-          'Nullam neque, ligula ac, id blandit.',
-          'Sagittis tincidunt torquent, tempor nunc amet.',
-          'At rhoncus id.',
-        ],
-      },
-      {
-        ab: [
-          'Sem nascetur, erat ut, non in.',
-          'A donec, venenatis pellentesque dis.',
-          'Mauris mauris.',
-          'Quisque nisl duis, facilisis viverra.',
-          'Justo purus, semper eget et.',
-        ],
-      },
-      {
-        a: [
-          'Est amet, vestibulum pellentesque.',
-          'Erat ligula.',
-          'Justo eros.',
-          'Fringilla quisque.',
-        ],
-      },
-      {
-        ab: [
-          'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          'Eros suspendisse.',
-        ],
-      },
-      {
-        a: [
-          'Rhoncus tempor, ultricies aliquam ipsum.',
-        ],
-        b: [
-          'Rhoncus tempor, ultricies praesent ipsum.',
-        ],
-        edit_a: [
-          [
-            26,
-            7,
-          ],
-        ],
-        edit_b: [
-          [
-            26,
-            8,
-          ],
-        ],
-      },
-      {
-        ab: [
-          'Sollicitudin duis.',
-          'Blandit blandit, ante nisl fusce.',
-          'Felis ac at, tellus consectetuer.',
-          'Sociis ligula sapien, egestas leo.',
-          'Cum pulvinar, sed mauris, cursus neque velit.',
-          'Augue porta lobortis.',
-          'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
-          'Id quam ipsum, id urna et, massa suspendisse.',
-          'Ac nec, nibh praesent.',
-          'Rutrum vestibulum.',
-          'Est tellus, bibendum habitasse.',
-          'Justo facilisis, vel nulla.',
-          'Donec eu, vulputate neque aliquam, nulla dui.',
-          'Risus adipiscing in.',
-          'Lacus arcu arcu.',
-          'Urna velit.',
-          'Urna a dolor.',
-          'Lectus magna augue, convallis mattis tortor, sed tellus ' +
-          'consequat.',
-          'Etiam dui, blandit wisi.',
-          'Mi nec.',
-          'Vitae eget vestibulum.',
-          'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
-          'Ac eget.',
-          'Vel fringilla, interdum pellentesque placerat, proin ante.',
-        ],
-      },
-      {
-        b: [
-          'Eu congue risus.',
-          'Enim ac, quis elementum.',
-          'Non et elit.',
-          'Etiam aliquam, diam vel nunc.',
-        ],
-      },
-      {
-        ab: [
-          'Nec at.',
-          'Arcu mauris, venenatis lacus fermentum, praesent duis.',
-          'Pellentesque amet et, tellus duis.',
-          'Ipsum arcu vitae, justo elit, sed libero tellus.',
-          'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
-        ],
-      },
-    ],
-  };
-}
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 28da1aa..ae8545a 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -68,6 +68,8 @@
   GroupId,
   GroupName,
   UrlEncodedRepoName,
+  NumericChangeId,
+  PreferencesInput,
 } from '../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
 import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -83,7 +85,6 @@
 import {
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
-  createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
 
@@ -255,15 +256,33 @@
   getChanges() {
     return Promise.resolve([]);
   },
+  getChangesForMultipleQueries() {
+    return Promise.resolve([]);
+  },
   getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> {
     return Promise.resolve(createSubmittedTogetherInfo());
   },
+  getDetailedChangesWithActions(changeNums: NumericChangeId[]) {
+    return Promise.resolve(
+      changeNums.map(changeNum => {
+        return {
+          ...createChange(),
+          actions: {},
+          _number: changeNum,
+          subject: `Subject ${changeNum}`,
+        };
+      })
+    );
+  },
   getChangesWithSameTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
   getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getChangesWithSimilarHashtag(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getConfig(): Promise<ServerInfo | undefined> {
     return Promise.resolve(createServerInfo());
   },
@@ -486,8 +505,9 @@
   saveIncludedGroup(): Promise<GroupInfo | undefined> {
     throw new Error('saveIncludedGroup() not implemented by RestApiMock.');
   },
-  savePreferences(): Promise<PreferencesInfo> {
-    return Promise.resolve(createDefaultPreferences());
+  savePreferences(input: PreferencesInput): Promise<PreferencesInfo> {
+    const info = input as PreferencesInfo;
+    return Promise.resolve({...info});
   },
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 7a9eee9..5795c4a 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -39,7 +39,7 @@
 import {ConfigModel, configModelToken} from '../models/config/config-model';
 import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
 import {PluginsModel} from '../models/plugins/plugins-model';
-import {MockHighlightService} from '../services/highlight/highlight-service_test';
+import {MockHighlightService} from '../services/highlight/highlight-service-mock';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
@@ -55,20 +55,23 @@
     restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new GrJsApiInterface(ctx.reportingService!);
+      return new GrJsApiInterface(ctx.reportingService);
     },
     storageService: (_ctx: Partial<AppContext>) => grStorageMock,
     userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserModel(ctx.restApiService!);
+      return new UserModel(ctx.restApiService);
     },
     shortcutsService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.userModel, 'userModel');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.userModel!, ctx.reportingService!);
+      return new ShortcutsService(ctx.userModel, ctx.reportingService);
     },
     pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
-    highlightService: (_ctx: Partial<AppContext>) => new MockHighlightService(),
+    highlightService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.reportingService, 'reportingService');
+      return new MockHighlightService(ctx.reportingService);
+    },
   };
   return create<AppContext>(appRegistry);
 }
@@ -84,7 +87,7 @@
   resolver: <T>(token: DependencyToken<T>) => T
 ): Map<DependencyToken<unknown>, Creator<unknown>> {
   const dependencies = new Map();
-  const browserModel = () => new BrowserModel(appContext.userModel!);
+  const browserModel = () => new BrowserModel(appContext.userModel);
   dependencies.set(browserModelToken, browserModel);
 
   const changeModelCreator = () =>
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 329ea11..8e8fe42 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -33,6 +33,7 @@
   CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentRange,
   CommitId,
   CommitInfo,
   ConfigInfo,
@@ -120,8 +121,21 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
-import {RunResult} from '../models/checks/checks-model';
+import {CheckResult, RunResult} from '../models/checks/checks-model';
 import {Category, RunStatus} from '../api/checks';
+import {DiffInfo} from '../api/diff';
+
+const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
+export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
+export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
+export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
+export const TEST_CHANGE_INFO_ID: ChangeInfoId =
+  `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
+export const TEST_SUBJECT = 'Test subject';
+export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
+
+export const TEST_CHANGE_CREATED = new Date(2020, 1, 1, 1, 2, 3);
+export const TEST_CHANGE_UPDATED = new Date(2020, 10, 6, 5, 12, 34);
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
@@ -201,21 +215,21 @@
   };
 }
 
+export function createAccountDetailWithIdNameAndEmail(
+  id = 5
+): AccountDetailInfo {
+  return {
+    _account_id: id as AccountId,
+    email: `user-${id}@` as EmailAddress,
+    name: `User-${id}`,
+    registered_on: dateToTimestamp(new Date(2020, 10, 15, 14, 5, 8)),
+  };
+}
+
 export function createReviewers(): Reviewers {
   return {};
 }
 
-export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
-export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
-export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
-export const TEST_CHANGE_INFO_ID: ChangeInfoId =
-  `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
-export const TEST_SUBJECT = 'Test subject';
-export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
-
-export const TEST_CHANGE_CREATED = new Date(2020, 1, 1, 1, 2, 3);
-export const TEST_CHANGE_UPDATED = new Date(2020, 10, 6, 5, 12, 34);
-
 export function createGitPerson(name = 'Test name'): GitPersonInfo {
   return {
     name,
@@ -455,6 +469,140 @@
   };
 }
 
+export function createEmptyDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'empty-left.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    meta_b: {
+      name: 'empty-right.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    content: [],
+  };
+}
+
+export function createDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 45,
+    },
+    meta_b: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 48,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    diff_header: [
+      'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+      'index b2adcf4..554ae49 100644',
+      '--- a/lorem-ipsum.txt',
+      '+++ b/lorem-ipsum.txt',
+    ],
+    content: [
+      {
+        ab: [
+          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
+            'nulla phasellus.',
+          'Mattis lectus.',
+          'Sodales duis.',
+          'Orci a faucibus.',
+        ],
+      },
+      {
+        b: [
+          'Nullam neque, ligula ac, id blandit.',
+          'Sagittis tincidunt torquent, tempor nunc amet.',
+          'At rhoncus id.',
+        ],
+      },
+      {
+        ab: [
+          'Sem nascetur, erat ut, non in.',
+          'A donec, venenatis pellentesque dis.',
+          'Mauris mauris.',
+          'Quisque nisl duis, facilisis viverra.',
+          'Justo purus, semper eget et.',
+        ],
+      },
+      {
+        a: [
+          'Est amet, vestibulum pellentesque.',
+          'Erat ligula.',
+          'Justo eros.',
+          'Fringilla quisque.',
+        ],
+      },
+      {
+        ab: [
+          'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          'Eros suspendisse.',
+        ],
+      },
+      {
+        a: ['Rhoncus tempor, ultricies aliquam ipsum.'],
+        b: ['Rhoncus tempor, ultricies praesent ipsum.'],
+        edit_a: [[26, 7]],
+        edit_b: [[26, 8]],
+      },
+      {
+        ab: [
+          'Sollicitudin duis.',
+          'Blandit blandit, ante nisl fusce.',
+          'Felis ac at, tellus consectetuer.',
+          'Sociis ligula sapien, egestas leo.',
+          'Cum pulvinar, sed mauris, cursus neque velit.',
+          'Augue porta lobortis.',
+          'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
+          'Id quam ipsum, id urna et, massa suspendisse.',
+          'Ac nec, nibh praesent.',
+          'Rutrum vestibulum.',
+          'Est tellus, bibendum habitasse.',
+          'Justo facilisis, vel nulla.',
+          'Donec eu, vulputate neque aliquam, nulla dui.',
+          'Risus adipiscing in.',
+          'Lacus arcu arcu.',
+          'Urna velit.',
+          'Urna a dolor.',
+          'Lectus magna augue, convallis mattis tortor, sed tellus ' +
+            'consequat.',
+          'Etiam dui, blandit wisi.',
+          'Mi nec.',
+          'Vitae eget vestibulum.',
+          'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+          'Ac eget.',
+          'Vel fringilla, interdum pellentesque placerat, proin ante.',
+        ],
+      },
+      {
+        b: [
+          'Eu congue risus.',
+          'Enim ac, quis elementum.',
+          'Non et elit.',
+          'Etiam aliquam, diam vel nunc.',
+        ],
+      },
+      {
+        ab: [
+          'Nec at.',
+          'Arcu mauris, venenatis lacus fermentum, praesent duis.',
+          'Pellentesque amet et, tellus duis.',
+          'Ipsum arcu vitae, justo elit, sed libero tellus.',
+          'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
+        ],
+      },
+    ],
+  };
+}
+
 export function createMergeable(): MergeableInfo {
   return {
     submit_type: SubmitType.MERGE_IF_NECESSARY,
@@ -523,6 +671,15 @@
   };
 }
 
+export function createRange(): CommentRange {
+  return {
+    start_line: 1,
+    start_character: 0,
+    end_line: 1,
+    end_character: 1,
+  };
+}
+
 export function createComment(
   extra: Partial<CommentInfo | DraftInfo> = {}
 ): CommentInfo {
@@ -754,20 +911,25 @@
   }
 }
 
-export function createSubmitRequirementExpressionInfo(): SubmitRequirementExpressionInfo {
+export function createSubmitRequirementExpressionInfo(
+  expression = TEST_DEFAULT_EXPRESSION
+): SubmitRequirementExpressionInfo {
   return {
-    expression: 'label:Verified=MAX -label:Verified=MIN',
+    expression,
     fulfilled: true,
     passing_atoms: ['label2:verified=MAX'],
     failing_atoms: ['label2:verified=MIN'],
   };
 }
 
-export function createSubmitRequirementResultInfo(): SubmitRequirementResultInfo {
+export function createSubmitRequirementResultInfo(
+  expression = TEST_DEFAULT_EXPRESSION
+): SubmitRequirementResultInfo {
   return {
     name: 'Verified',
     status: SubmitRequirementStatus.SATISFIED,
-    submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    submittability_expression_result:
+      createSubmitRequirementExpressionInfo(expression),
     is_legacy: false,
   };
 }
@@ -798,6 +960,14 @@
   };
 }
 
+export function createCheckResult(): CheckResult {
+  return {
+    category: Category.ERROR,
+    summary: 'error',
+    internalResultId: 'test-internal-result-id',
+  };
+}
+
 export function createDetailedLabelInfo(): DetailedLabelInfo {
   return {
     values: {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 333ae39..ea7865e 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -27,21 +27,28 @@
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
-import {Key, Modifier} from '../utils/dom-util';
+import {afterNextRender, Key, Modifier} from '../utils/dom-util';
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
+import {HighlightService} from '../services/highlight/highlight-service';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise<T> extends Promise<T> {
   resolve: (value?: T) => void;
+  reject: (reason?: any) => void;
 }
 
 export function mockPromise<T = unknown>(): MockPromise<T> {
   let res: (value?: T) => void;
-  const promise: MockPromise<T> = new Promise<T | undefined>(resolve => {
-    res = resolve;
-  }) as MockPromise<T>;
+  let rej: (reason?: any) => void;
+  const promise: MockPromise<T> = new Promise<T | undefined>(
+    (resolve, reject) => {
+      res = resolve;
+      rej = reject;
+    }
+  ) as MockPromise<T>;
   promise.resolve = res!;
+  promise.reject = rej!;
   return promise;
 }
 
@@ -121,6 +128,12 @@
   return sinon.stub(getAppContext().shortcutsService, method);
 }
 
+export function stubHighlightService<K extends keyof HighlightService>(
+  method: K
+) {
+  return sinon.stub(getAppContext().highlightService, method);
+}
+
 export function stubStorage<K extends keyof StorageService>(method: K) {
   return sinon.stub(getAppContext().storageService, method);
 }
@@ -193,10 +206,12 @@
   return new Promise((resolve, reject) => {
     const waiter = () => {
       if (predicate()) {
-        return resolve();
+        resolve();
+        return;
       }
       if (Date.now() - start >= 1000) {
-        return reject(error);
+        reject(error);
+        return;
       }
       setTimeout(waiter, sleep);
       sleep = sleep === 0 ? 1 : sleep * 4;
@@ -209,6 +224,10 @@
   return waitUntil(() => stub.called, `${name} was not called`);
 }
 
+export async function nextRender() {
+  return new Promise(resolve => afterNextRender(resolve));
+}
+
 /**
  * Subscribes to the observable and resolves once it emits a matching value.
  * Usage:
@@ -281,6 +300,19 @@
   element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));
 }
 
+export function mouseDown(element: HTMLElement) {
+  const rect = element.getBoundingClientRect();
+  const eventOptions = {
+    bubbles: true,
+    composed: true,
+    clientX: (rect.left + rect.right) / 2,
+    clientY: (rect.top + rect.bottom) / 2,
+    screenX: (rect.left + rect.right) / 2,
+    screenY: (rect.top + rect.bottom) / 2,
+  };
+  element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
+}
+
 export function assertFails(promise: Promise<unknown>, error?: unknown) {
   promise
     .then((_v: unknown) => {
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 2e51f6e..3e68d6c 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -96,7 +96,6 @@
     "gr-diff/**/*",
     "mixins/**/*",
     "models/**/*",
-    "samples/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index dba3060..730fc4d 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -17,7 +17,6 @@
     "gr-diff/**/*",
     "mixins/**/*",
     "models/**/*",
-    "samples/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
@@ -29,4 +28,4 @@
     "**/*_test.ts",
     "**/*_test.js"
   ]
-}
\ No newline at end of file
+}
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 78bb350..096bd2f 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -23,7 +23,6 @@
     "gr-diff/**/*",
     "mixins/**/*",
     "models/**/*",
-    "samples/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
@@ -33,4 +32,4 @@
     "workers/**/*"
   ],
   "exclude": []
-}
\ No newline at end of file
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 66af2d0..c36fa8a 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -85,7 +85,7 @@
   LabelInfo,
   LabelNameToInfoMap,
   LabelNameToLabelTypeInfoMap,
-  LabelNameToValueMap,
+  LabelNameToValuesMap,
   LabelTypeInfo,
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
@@ -174,7 +174,7 @@
   LabelInfo,
   LabelNameToInfoMap,
   LabelNameToLabelTypeInfoMap,
-  LabelNameToValueMap,
+  LabelNameToValuesMap,
   LabelTypeInfo,
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
@@ -1169,7 +1169,7 @@
 export interface ReviewInput {
   message?: string;
   tag?: ReviewInputTag;
-  labels?: LabelNameToValuesMap;
+  labels?: LabelNameToValueMap;
   comments?: PathToCommentsInputMap;
   robot_comments?: PathToRobotCommentsMap;
   drafts?: DraftsAction;
@@ -1211,7 +1211,7 @@
   confirm?: boolean;
 }
 
-export type LabelNameToValuesMap = {[labelName: string]: number};
+export type LabelNameToValueMap = {[labelName: string]: number};
 export type PathToCommentsInputMap = {[path: string]: CommentInput[]};
 export type PathToRobotCommentsMap = {[path: string]: RobotCommentInput[]};
 export type RecipientTypeToNotifyInfoMap = {
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 562d47f..7ad656d 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -48,9 +48,9 @@
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
+  meta_a?: DiffFileMetaInfo;
   /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  meta_b?: DiffFileMetaInfo;
 
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 08a625c..b6018ba 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -35,7 +35,7 @@
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
-  if (account._account_id) return account._account_id;
+  if (account._account_id !== undefined) return account._account_id;
   if (account.email) return account.email;
   throw new Error('Account has neither _account_id nor email.');
 }
@@ -97,7 +97,7 @@
 }
 
 /**
- * @desc Get account in pseudonymized form, that can be send to the backend.
+ * Get account in pseudonymized form, that can be send to the backend.
  *
  * If account is not present, returns anonymous user name according to config.
  */
@@ -108,7 +108,7 @@
 }
 
 /**
- * @desc Replace account templates with user display names in text, received from the backend.
+ * Replace account templates with user display names in text, received from the backend.
  */
 export function replaceTemplates(
   text: string,
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.js b/polygerrit-ui/app/utils/admin-nav-util_test.ts
similarity index 78%
rename from polygerrit-ui/app/utils/admin-nav-util_test.js
rename to polygerrit-ui/app/utils/admin-nav-util_test.ts
index 2a13904..e06664e 100644
--- a/polygerrit-ui/app/utils/admin-nav-util_test.js
+++ b/polygerrit-ui/app/utils/admin-nav-util_test.ts
@@ -15,23 +15,30 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {getAdminLinks} from './admin-nav-util.js';
+import {AccountDetailInfo, GroupId, RepoName, Timestamp} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {AdminNavLinksOption, getAdminLinks} from './admin-nav-util';
 
 suite('gr-admin-nav-behavior tests', () => {
-  let capabilityStub;
-  let menuLinkStub;
+  let capabilityStub: sinon.SinonStub;
+  let menuLinkStub: sinon.SinonStub;
 
   setup(() => {
     capabilityStub = sinon.stub();
     menuLinkStub = sinon.stub().returns([]);
   });
 
-  const testAdminLinks = async (account, options, expected) => {
-    const res = await getAdminLinks(account,
-        capabilityStub,
-        menuLinkStub,
-        options);
+  const testAdminLinks = async (
+    account: AccountDetailInfo | undefined,
+    options: AdminNavLinksOption | undefined,
+    expected: any
+  ) => {
+    const res = await getAdminLinks(
+      account,
+      capabilityStub,
+      menuLinkStub,
+      options
+    );
 
     assert.equal(expected.totalLength, res.links.length);
     assert.equal(res.links[0].name, 'Repositories');
@@ -47,30 +54,33 @@
 
     if (expected.projectPageShown) {
       assert.isOk(res.links[0].subsection);
-      assert.equal(res.links[0].subsection.children.length, 6);
+      assert.equal(res.links[0].subsection!.children!.length, 6);
     } else {
       assert.isNotOk(res.links[0].subsection);
     }
     // Groups
     if (expected.groupPageShown) {
       assert.isOk(res.links[1].subsection);
-      assert.equal(res.links[1].subsection.children.length,
-          expected.groupSubpageLength);
-    } else if ( expected.totalLength > 1) {
+      assert.equal(
+        res.links[1].subsection!.children!.length,
+        expected.groupSubpageLength
+      );
+    } else if (expected.totalLength > 1) {
       assert.isNotOk(res.links[1].subsection);
     }
 
     if (expected.pluginGeneratedLinks) {
       for (const link of expected.pluginGeneratedLinks) {
-        const linkMatch = res.links
-            .find(l => (l.url === link.url && l.name === link.text));
+        const linkMatch = res.links.find(
+          l => l.url === link.url && l.name === link.text
+        );
         assert.isTrue(!!linkMatch);
 
         // External links should open in new tab.
         if (link.url[0] !== '/') {
-          assert.equal(linkMatch.target, '_blank');
+          assert.equal(linkMatch!.target, '_blank');
         } else {
-          assert.isNotOk(linkMatch.target);
+          assert.isNotOk(linkMatch!.target);
         }
       }
     }
@@ -78,23 +88,25 @@
     // Current section
     if (expected.projectPageShown || expected.groupPageShown) {
       assert.isOk(res.expandedSection);
-      assert.isOk(res.expandedSection.children);
+      assert.isOk(res.expandedSection!.children);
     } else {
       assert.isNotOk(res.expandedSection);
     }
     if (expected.projectPageShown) {
-      assert.equal(res.expandedSection.name, 'my-repo');
-      assert.equal(res.expandedSection.children.length, 6);
+      assert.equal(res.expandedSection!.name, 'my-repo');
+      assert.equal(res.expandedSection!.children!.length, 6);
     } else if (expected.groupPageShown) {
-      assert.equal(res.expandedSection.name, 'my-group');
-      assert.equal(res.expandedSection.children.length,
-          expected.groupSubpageLength);
+      assert.equal(res.expandedSection!.name, 'my-group');
+      assert.equal(
+        res.expandedSection!.children!.length,
+        expected.groupSubpageLength
+      );
     }
   };
 
   suite('logged out', () => {
-    let account;
-    let expected;
+    let account: AccountDetailInfo;
+    let expected: any;
 
     setup(() => {
       expected = {
@@ -114,7 +126,7 @@
     });
 
     test('with a repo', async () => {
-      const options = {repoName: 'my-repo'};
+      const options = {repoName: 'my-repo' as RepoName};
       expected = Object.assign(expected, {
         totalLength: 1,
         projectPageShown: true,
@@ -141,8 +153,9 @@
   suite('no plugin capability logged in', () => {
     const account = {
       name: 'test-user',
+      registered_on: '' as Timestamp,
     };
-    let expected;
+    let expected: any;
 
     setup(() => {
       expected = {
@@ -165,8 +178,9 @@
     test('with a repo', async () => {
       const account = {
         name: 'test-user',
+        registered_on: '' as Timestamp,
       };
-      const options = {repoName: 'my-repo'};
+      const options = {repoName: 'my-repo' as RepoName};
       expected = Object.assign(expected, {
         projectPageShown: true,
         groupListShown: true,
@@ -179,8 +193,9 @@
   suite('view plugin capability logged in', () => {
     const account = {
       name: 'test-user',
+      registered_on: '' as Timestamp,
     };
-    let expected;
+    let expected: any;
 
     setup(() => {
       capabilityStub.returns(Promise.resolve({viewPlugins: true}));
@@ -201,7 +216,7 @@
     });
 
     test('with a repo', async () => {
-      const options = {repoName: 'my-repo'};
+      const options = {repoName: 'my-repo' as RepoName};
       expected = Object.assign(expected, {
         projectPageShown: true,
         groupPageShown: false,
@@ -211,7 +226,7 @@
 
     test('admin with internal group', async () => {
       const options = {
-        groupId: 'a15262',
+        groupId: 'a15262' as GroupId,
         groupName: 'my-group',
         groupIsInternal: true,
         isAdmin: true,
@@ -227,7 +242,7 @@
 
     test('group owner with internal group', async () => {
       const options = {
-        groupId: 'a15262',
+        groupId: 'a15262' as GroupId,
         groupName: 'my-group',
         groupIsInternal: true,
         isAdmin: false,
@@ -243,7 +258,7 @@
 
     test('non owner or admin with internal group', async () => {
       const options = {
-        groupId: 'a15262',
+        groupId: 'a15262' as GroupId,
         groupName: 'my-group',
         groupIsInternal: true,
         isAdmin: false,
@@ -259,7 +274,7 @@
 
     test('admin with external group', async () => {
       const options = {
-        groupId: 'a15262',
+        groupId: 'a15262' as GroupId,
         groupName: 'my-group',
         groupIsInternal: false,
         isAdmin: true,
@@ -277,8 +292,9 @@
   suite('view plugin screen with plugin capability', () => {
     const account = {
       name: 'test-user',
+      registered_on: '' as Timestamp,
     };
-    let expected;
+    let expected: any;
 
     setup(() => {
       capabilityStub.returns(Promise.resolve({pluginCapability: true}));
@@ -289,9 +305,7 @@
       let options;
       const generatedLinks = [
         {text: 'without capability', url: '/without'},
-        {text: 'with capability',
-          url: '/with',
-          capability: 'pluginCapability'},
+        {text: 'with capability', url: '/with', capability: 'pluginCapability'},
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
@@ -305,8 +319,9 @@
   suite('view plugin screen without plugin capability', () => {
     const account = {
       name: 'test-user',
+      registered_on: '' as Timestamp,
     };
-    let expected;
+    let expected: any;
 
     setup(() => {
       capabilityStub.returns(Promise.resolve({}));
@@ -317,9 +332,7 @@
       let options;
       const generatedLinks = [
         {text: 'without capability', url: '/without'},
-        {text: 'with capability',
-          url: '/with',
-          capability: 'pluginCapability'},
+        {text: 'with capability', url: '/with', capability: 'pluginCapability'},
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
@@ -330,4 +343,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 3f51532..981bcae 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -146,3 +146,27 @@
 }
 
 export const isFalse = (b: boolean) => b === false;
+
+export type PromiseResult<T> =
+  | {status: 'fulfilled'; value: T}
+  | {status: 'rejected'; reason: string};
+export function isFulfilled<T>(
+  promiseResult?: PromiseResult<T>
+): promiseResult is PromiseResult<T> & {status: 'fulfilled'} {
+  return promiseResult?.status === 'fulfilled';
+}
+
+// An equivalent to Promise.allSettled from ES2020.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
+// TODO: Migrate our tooling to ES2020 and remove this method.
+export function allSettled<T>(
+  promises: Promise<T>[]
+): Promise<PromiseResult<T>[]> {
+  return Promise.all(
+    promises.map(promise =>
+      promise
+        .then(value => ({status: 'fulfilled', value} as const))
+        .catch(reason => ({status: 'rejected', reason} as const))
+    )
+  );
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index b0b7ef8..0347581 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -49,7 +49,7 @@
   if (change?.attention_set === undefined) return '';
   if (account?._account_id === undefined) return '';
 
-  const attentionSetInfo = change.attention_set[account._account_id!];
+  const attentionSetInfo = change.attention_set[account._account_id];
 
   if (attentionSetInfo?.reason === undefined) return '';
 
diff --git a/polygerrit-ui/app/utils/bulk-flow-util.ts b/polygerrit-ui/app/utils/bulk-flow-util.ts
new file mode 100644
index 0000000..9a6179a
--- /dev/null
+++ b/polygerrit-ui/app/utils/bulk-flow-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ProgressStatus} from '../constants/constants';
+import {NumericChangeId} from '../api/rest-api';
+
+export function getOverallStatus(
+  progressByChangeNum: Map<NumericChangeId, ProgressStatus>
+) {
+  const statuses = Array.from(progressByChangeNum.values());
+  if (statuses.every(s => s === ProgressStatus.NOT_STARTED)) {
+    return ProgressStatus.NOT_STARTED;
+  }
+  if (statuses.some(s => s === ProgressStatus.RUNNING)) {
+    return ProgressStatus.RUNNING;
+  }
+  if (statuses.some(s => s === ProgressStatus.FAILED)) {
+    return ProgressStatus.FAILED;
+  }
+  return ProgressStatus.SUCCESSFUL;
+}
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 501da7d..669c491 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -38,6 +38,7 @@
 import {isMergeParent, getParentIndex} from './patch-set-util';
 import {DiffInfo} from '../types/diff';
 import {LineNumber} from '../api/diff';
+import {FormattedReviewerUpdateInfo} from '../types/types';
 
 export interface DraftCommentProps {
   // This must be true for all drafts. Drafts received from the backend will be
@@ -98,6 +99,12 @@
   commentThreads: CommentThread[];
 }
 
+export function isFormattedReviewerUpdate(
+  message: ChangeMessage
+): message is ChangeMessage & FormattedReviewerUpdateInfo {
+  return message.type === 'REVIEWER_UPDATE';
+}
+
 export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
 
 export const PATCH_SET_PREFIX_PATTERN =
@@ -111,7 +118,7 @@
 
     const date1 = parseDate(c1.updated);
     const date2 = parseDate(c2.updated);
-    const dateDiff = date1!.valueOf() - date2!.valueOf();
+    const dateDiff = date1.valueOf() - date2.valueOf();
     if (dateDiff !== 0) return dateDiff;
 
     const id1 = c1.id;
@@ -390,7 +397,7 @@
     };
   } else {
     return {
-      patchNum: latestPatchNum as RevisionPatchSetNum,
+      patchNum: latestPatchNum,
       basePatchNum: comment.patch_set as BasePatchSetNum,
     };
   }
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 9e3bc74..95b753c 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -156,3 +156,26 @@
 export function unique<T>(item: T, index: number, array: T[]) {
   return array.indexOf(item) === index;
 }
+
+/**
+ * Returns the elements that are present in every sub-array. If a compareBy
+ * predicate is passed in, it will be used instead of strict equality. A new
+ * array is always returned even if there is already just a single array.
+ */
+export function intersection<T>(
+  arrays: T[][],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  // Array.prototype.reduce needs either an initialValue or a non-empty array.
+  // Since there is no good initialValue for intersecting (∅ ∩ X = ∅), the
+  // empty array must be checked separately.
+  if (arrays.length === 0) {
+    return [];
+  }
+  if (arrays.length === 1) {
+    return [...arrays[0]];
+  }
+  return arrays.reduce((result, array) =>
+    result.filter(t => array.find(u => compareBy(t, u)))
+  );
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 4156729..0adfaa6 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -16,7 +16,12 @@
  */
 
 import '../test/common-test-setup-karma';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
+import {
+  hasOwnProperty,
+  areSetsEqual,
+  containsAll,
+  intersection,
+} from './common-util';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
@@ -68,4 +73,31 @@
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
   });
+
+  test('intersections', () => {
+    const arrayWithValues = [1, 2, 3];
+    assert.sameDeepMembers(intersection([]), []);
+    assert.sameDeepMembers(intersection([arrayWithValues]), arrayWithValues);
+    // a new array is returned even if a single array is provided.
+    assert.notStrictEqual(intersection([arrayWithValues]), arrayWithValues);
+    assert.sameDeepMembers(
+      intersection([
+        [1, 2, 3],
+        [2, 3, 4],
+        [5, 3, 2],
+      ]),
+      [2, 3]
+    );
+
+    const foo1 = {value: 5};
+    const foo2 = {value: 5};
+
+    // these foo's will fail strict equality with each other, but a comparator
+    // can make them intersect.
+    assert.sameDeepMembers(intersection([[foo1], [foo2]]), []);
+    assert.sameDeepMembers(
+      intersection([[foo1], [foo2]], (a, b) => a.value === b.value),
+      [foo1]
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index a780af5..6d37ef6 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -44,7 +44,10 @@
 export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
   const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
-  if (secondsAgo <= 59) return 'just now';
+  if (secondsAgo <= 59) {
+    if (noAgo) return `${secondsAgo} seconds`;
+    return 'just now';
+  }
   if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.floor(secondsAgo / 60);
   if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 16e0586..f2e0994 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -505,3 +505,14 @@
   });
   obs.observe(el);
 }
+
+/**
+ * Mimics a Polymer utility. `requestAnimationFrame` is called before the next
+ * browser paint. An additional `setTimeout` ensures that the paint has
+ * actually happened.
+ */
+export function afterNextRender(callback: (value?: unknown) => void) {
+  requestAnimationFrame(() => {
+    setTimeout(callback);
+  });
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 2018eeb..e624cef 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -32,7 +32,7 @@
   );
 }
 
-type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
+export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT>
     ? unknown extends DT
       ? never
@@ -69,7 +69,7 @@
 }
 
 export function fireAlert(target: EventTarget, message: string) {
-  fire(target, EventType.SHOW_ALERT, {message});
+  fire(target, EventType.SHOW_ALERT, {message, showDismiss: true});
 }
 
 export function firePageError(response?: Response | null) {
diff --git a/polygerrit-ui/app/utils/focusable.ts b/polygerrit-ui/app/utils/focusable.ts
new file mode 100644
index 0000000..d5bed09
--- /dev/null
+++ b/polygerrit-ui/app/utils/focusable.ts
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const FOCUSABLE_QUERY =
+  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Gets an ordered list of focusable elements nested within a containing
+ * element that may contain shadow DOMs.
+ *
+ * This goes depth-first, so that the order of elements follows the a11y tree.
+ */
+export function* getFocusableElements(
+  el: HTMLElement | SVGElement
+): Generator<HTMLElement | SVGElement> {
+  const style = window.getComputedStyle(el);
+  if (style.display === 'none' || style.visibility === 'hidden') return;
+  if (el.matches(FOCUSABLE_QUERY)) {
+    yield el;
+  }
+
+  let children = [];
+  if (el.localName === 'slot') {
+    children = (el as HTMLSlotElement).assignedNodes({flatten: true});
+  } else {
+    children = [...(el.shadowRoot || el).children];
+  }
+
+  for (const node of children.filter(
+    node => node instanceof HTMLElement || node instanceof SVGElement
+  )) {
+    yield* getFocusableElements(node as HTMLElement | SVGElement);
+  }
+}
+
+/**
+ * Gets an ordered list of focusable elements nested within a containing
+ * element that may contain shadow DOMs.
+ *
+ * This returns in reverse a11 order.
+ */
+export function* getFocusableElementsReverse(
+  el: HTMLElement | SVGElement
+): Generator<HTMLElement | SVGElement> {
+  const style = window.getComputedStyle(el);
+  if (style.display === 'none' || style.visibility === 'hidden') return;
+
+  let children = [];
+  if (el.localName === 'slot') {
+    children = (el as HTMLSlotElement).assignedNodes({flatten: true});
+  } else {
+    children = [...(el.shadowRoot || el).children];
+  }
+
+  for (const node of children
+    .filter(node => node instanceof HTMLElement || node instanceof SVGElement)
+    .reverse()) {
+    yield* getFocusableElementsReverse(node as HTMLElement | SVGElement);
+  }
+
+  if (el.matches(FOCUSABLE_QUERY)) {
+    yield el;
+  }
+}
diff --git a/polygerrit-ui/app/utils/focusable_test.ts b/polygerrit-ui/app/utils/focusable_test.ts
new file mode 100644
index 0000000..cbc3185
--- /dev/null
+++ b/polygerrit-ui/app/utils/focusable_test.ts
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../test/common-test-setup-karma';
+import {getFocusableElements, getFocusableElementsReverse} from './focusable';
+import {html, render} from 'lit';
+import {fixture} from '@open-wc/testing-helpers';
+
+async function createDom() {
+  const container = await fixture<HTMLDivElement>(html`<div></div>`);
+  const shadow = container.attachShadow({mode: 'open'});
+  render(
+    html`
+      <a href="" id="first">A link</a>
+      <slot></slot>
+      <button id="third">A button</button>
+      <button class="not" style="display: none">No Display Button</button>
+      <button class="not" style="visibility: hidden">Hidden Button</button>
+      <span id="fourth" tabindex="0">Focusable Span</span>
+      <textarea id="fifth">TextArea</textarea>
+      <div id="moreshadow">
+        <button class="not in shadow"></button>
+      </div>
+    `,
+    shadow
+  );
+  const slottedContent = document.createElement('div');
+  render(
+    html` <textarea id="second">Slotted TextArea</textarea> `,
+    slottedContent
+  );
+  container.appendChild(slottedContent);
+  const slot: HTMLSlotElement | null = shadow.querySelector('slot');
+  // For some reason Typescript doesn't know about the `assign` method on
+  // HTMLSlotElement.
+  //
+  (slot! as any).assign(slottedContent);
+  const moreShadow = shadow
+    .querySelector('#moreshadow')!
+    .attachShadow({mode: 'open'});
+  render(
+    html`
+      <form>
+        <input id="sixth" type="submit" value="Submit" />
+      </form>
+    `,
+    moreShadow
+  );
+  return container;
+}
+
+suite('focusable', () => {
+  test('Finds all focusables in-order', async () => {
+    const container = await createDom();
+    const results = [...getFocusableElements(container)];
+    expect(results.map(e => e.id)).to.have.ordered.members([
+      'first',
+      'second',
+      'third',
+      'fourth',
+      'fifth',
+      'sixth',
+    ]);
+  });
+
+  test('Finds all focusables in reverse order', async () => {
+    const container = await createDom();
+    const results = [...getFocusableElementsReverse(container)];
+    expect(results.map(e => e.id)).to.have.ordered.members([
+      'sixth',
+      'fifth',
+      'fourth',
+      'third',
+      'second',
+      'first',
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts
index 549f493..6183b53 100644
--- a/polygerrit-ui/app/utils/inner-html-util.ts
+++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -1,36 +1,16 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
-// This file adds some simple checks to match internal google rules.
-// Internally in google it has different implementation
+// This file adds some simple checks to match internal Google rules.
+// Internally at Google it has different a implementation.
 
 import {BrandType} from '../types/common';
 
-export type SafeHtml = BrandType<string, '_safeHtml'>;
 export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
 
-export function setInnerHtml(el: HTMLElement, innerHTML: SafeHtml) {
-  el.innerHTML = innerHTML;
-}
-
-export function createStyle(styleSheet: SafeStyleSheet): SafeHtml {
-  return `<style>${styleSheet}</style>` as SafeHtml;
-}
-
 export function safeStyleSheet(
   templateObj: TemplateStringsArray
 ): SafeStyleSheet {
@@ -40,3 +20,9 @@
   }
   return styleSheet as SafeStyleSheet;
 }
+
+export const safeStyleEl = {
+  setTextContent: (elem: HTMLStyleElement, safeStyleSheet: SafeStyleSheet) => {
+    elem.textContent = safeStyleSheet;
+  },
+};
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 563e864..2b6f700 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -19,8 +19,8 @@
   isQuickLabelInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
+  LabelNameToValuesMap,
 } from '../api/rest-api';
-import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -31,7 +31,12 @@
   VotingRangeInfo,
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
-import {assertNever, unique} from './common-util';
+import {assertNever, unique, hasOwnProperty} from './common-util';
+
+export interface Label {
+  name: string;
+  value: string | null;
+}
 
 // Name of the standard Code-Review label.
 export enum StandardLabels {
@@ -294,6 +299,114 @@
   return priorityRequirementList.concat(nonPriorityRequirements);
 }
 
+function getStringLabelValue(
+  labels: LabelNameToInfoMap,
+  labelName: string,
+  numberValue?: number
+): string {
+  const detailedInfo = labels[labelName] as DetailedLabelInfo;
+  if (detailedInfo.values) {
+    for (const labelValue of Object.keys(detailedInfo.values)) {
+      if (Number(labelValue) === numberValue) {
+        return labelValue;
+      }
+    }
+  }
+  // TODO: This code is sometimes executed with numberValue taking the
+  // values 0 and undefined.
+  // For now it is unclear how this is happening, ideally this code should
+  // never be executed.
+  return `${numberValue}`;
+}
+
+export function getDefaultValue(
+  labels?: LabelNameToInfoMap,
+  labelName?: string
+) {
+  if (!labelName || !labels?.[labelName]) return undefined;
+  const labelInfo = labels[labelName] as DetailedLabelInfo;
+  return labelInfo.default_value;
+}
+
+export function getVoteForAccount(
+  labelName: string,
+  account?: AccountInfo,
+  change?: ParsedChangeInfo | ChangeInfo
+): string | null {
+  const labels = change?.labels;
+  if (!account || !labels) return null;
+  const votes = labels[labelName] as DetailedLabelInfo;
+  if (!votes.all?.length) return null;
+  for (let i = 0; i < votes.all.length; i++) {
+    if (votes.all[i]._account_id === account._account_id) {
+      return getStringLabelValue(labels, labelName, votes.all[i].value);
+    }
+  }
+  return null;
+}
+
+export function computeOrderedLabelValues(
+  permittedLabels?: LabelNameToValuesMap
+) {
+  if (!permittedLabels) return [];
+  const labels = Object.keys(permittedLabels);
+  const values: Set<number> = new Set();
+  for (const label of labels) {
+    for (const value of permittedLabels[label]) {
+      values.add(Number(value));
+    }
+  }
+
+  return Array.from(values.values()).sort((a, b) => a - b);
+}
+
+export function mergeLabelInfoMaps(
+  a?: LabelNameToInfoMap,
+  b?: LabelNameToInfoMap
+): LabelNameToInfoMap {
+  if (!a || !b) return {};
+  const mergedMap: LabelNameToInfoMap = {};
+  for (const key of Object.keys(a)) {
+    if (!hasOwnProperty(b, key)) continue;
+    mergedMap[key] = a[key];
+  }
+  return mergedMap;
+}
+
+export function mergeLabelMaps(
+  a?: LabelNameToValuesMap,
+  b?: LabelNameToValuesMap
+): LabelNameToValuesMap {
+  if (!a || !b) return {};
+  const mergedMap: LabelNameToValuesMap = {};
+  for (const key of Object.keys(a)) {
+    if (!hasOwnProperty(b, key)) continue;
+    mergedMap[key] = mergeLabelValues(a[key], b[key]);
+  }
+  return mergedMap;
+}
+
+export function mergeLabelValues(a: string[], b: string[]) {
+  return a.filter(value => b.includes(value));
+}
+
+export function computeLabels(
+  account?: AccountInfo,
+  change?: ParsedChangeInfo | ChangeInfo
+): Label[] {
+  if (!account) return [];
+  const labelsObj = change?.labels;
+  if (!labelsObj) return [];
+  return Object.keys(labelsObj)
+    .sort(labelCompare)
+    .map(key => {
+      return {
+        name: key,
+        value: getVoteForAccount(key, account, change),
+      };
+    });
+}
+
 export function getTriggerVotes(change?: ParsedChangeInfo | ChangeInfo) {
   const allLabels = Object.keys(change?.labels ?? {});
   // Normally there is utility method getRequirements, which filter out
@@ -307,16 +420,3 @@
     label => !labelAssociatedWithSubmitReqs.includes(label)
   );
 }
-
-export function showNewSubmitRequirements(
-  flagsService: FlagsService,
-  change?: ParsedChangeInfo | ChangeInfo
-) {
-  const isSubmitRequirementsUiEnabled = flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-  if (!isSubmitRequirementsUiEnabled) return false;
-  if ((getRequirements(change) ?? []).length === 0) return false;
-
-  return true;
-}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index f4e1da9..e655789 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -29,6 +29,10 @@
   hasNeutralStatus,
   labelCompare,
   LabelStatus,
+  computeLabels,
+  mergeLabelMaps,
+  computeOrderedLabelValues,
+  mergeLabelInfoMaps,
 } from './label-util';
 import {
   AccountId,
@@ -45,10 +49,12 @@
   createSubmitRequirementResultInfo,
   createNonApplicableSubmitRequirementResultInfo,
   createDetailedLabelInfo,
+  createAccountWithId,
 } from '../test/test-data-generators';
 import {
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
+  LabelNameToInfoMap,
 } from '../api/rest-api';
 
 const VALUES_0 = {
@@ -237,33 +243,254 @@
     assert.equal(getRepresentativeValue(labelInfo), -2);
   });
 
-  suite('extractAssociatedLabels()', () => {
-    function createSubmitRequirementExpressionInfoWith(expression: string) {
-      return {
-        ...createSubmitRequirementResultInfo(),
-        submittability_expression_result: {
-          ...createSubmitRequirementExpressionInfo(),
-          expression,
-        },
-      };
-    }
+  test('computeOrderedLabelValues', () => {
+    const labelValues = computeOrderedLabelValues({
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: ['-1', ' 0', '+1'],
+    });
+    assert.deepEqual(labelValues, [-2, -1, 0, 1, 2]);
+  });
 
+  test('computeLabels', async () => {
+    const accountId = 123 as AccountId;
+    const account = createAccountWithId(accountId);
+    const change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        } as DetailedLabelInfo,
+        Verified: {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        } as DetailedLabelInfo,
+      } as LabelNameToInfoMap,
+    };
+    let labels = computeLabels(account, change);
+    assert.deepEqual(labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: null},
+    ]);
+    change.labels = {
+      ...change.labels,
+      Verified: {
+        ...change.labels.Verified,
+        all: [
+          {
+            _account_id: accountId,
+            value: 1,
+          },
+        ],
+      } as DetailedLabelInfo,
+    } as LabelNameToInfoMap;
+    labels = computeLabels(account, change);
+    assert.deepEqual(labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: '+1'},
+    ]);
+  });
+
+  test('mergeLabelInfoMaps', () => {
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        undefined
+      ),
+      {}
+    );
+    assert.deepEqual(
+      mergeLabelInfoMaps(undefined, {
+        A: createDetailedLabelInfo(),
+        B: createDetailedLabelInfo(),
+      }),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        }
+      ),
+      {
+        A: createDetailedLabelInfo(),
+        B: createDetailedLabelInfo(),
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        {
+          B: createDetailedLabelInfo(),
+          C: createDetailedLabelInfo(),
+        }
+      ),
+      {
+        B: createDetailedLabelInfo(),
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        {
+          X: createDetailedLabelInfo(),
+          Y: createDetailedLabelInfo(),
+        }
+      ),
+      {}
+    );
+  });
+
+  test('mergeLabelMaps', () => {
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        undefined
+      ),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(undefined, {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+        C: ['-1', '0'],
+        D: ['0'],
+      }),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+        C: ['-1', '0'],
+        D: ['0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+        },
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        {
+          A: [],
+          B: ['-1', '0'],
+          C: ['0', '+1'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: [],
+        B: ['-1', '0'],
+        C: ['0'],
+        D: ['0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+        },
+        {
+          X: ['-1', '0', '+1', '+2'],
+          Y: ['-1', '0'],
+          Z: ['0'],
+        }
+      ),
+      {}
+    );
+  });
+
+  suite('extractAssociatedLabels()', () => {
     test('1 label', () => {
-      const submitRequirement = createSubmitRequirementExpressionInfoWith(
-        'label:Verified=MAX -label:Verified=MIN'
-      );
+      const submitRequirement = createSubmitRequirementResultInfo();
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['Verified']);
     });
     test('label with number', () => {
-      const submitRequirement = createSubmitRequirementExpressionInfoWith(
+      const submitRequirement = createSubmitRequirementResultInfo(
         'label2:verified=MAX'
       );
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['verified']);
     });
     test('2 labels', () => {
-      const submitRequirement = createSubmitRequirementExpressionInfoWith(
+      const submitRequirement = createSubmitRequirementResultInfo(
         'label:Verified=MAX -label:Code-Review=MIN'
       );
       const labels = extractAssociatedLabels(submitRequirement);
@@ -271,13 +498,10 @@
     });
     test('overridden label', () => {
       const submitRequirement = {
-        ...createSubmitRequirementExpressionInfoWith(
-          'label:Verified=MAX -label:Verified=MIN'
+        ...createSubmitRequirementResultInfo(),
+        override_expression_result: createSubmitRequirementExpressionInfo(
+          'label:Build-cop-override'
         ),
-        override_expression_result: {
-          ...createSubmitRequirementExpressionInfo(),
-          expression: 'label:Build-cop-override',
-        },
       };
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['Verified', 'Build-cop-override']);
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 0a0928e..ae255ab 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -51,3 +51,21 @@
 export function capitalizeFirstLetter(str: string) {
   return str.charAt(0).toUpperCase() + str.slice(1);
 }
+
+/**
+ * Converts the items into a sentence-friendly format. Examples:
+ * listForSentence(["Foo", "Bar", "Baz"])
+ * => 'Foo, Bar, and Baz'
+ * listForSentence(["Foo", "Bar"])
+ * => 'Foo and Bar'
+ * listForSentence(["Foo"])
+ * => 'Foo'
+ */
+export function listForSentence(items: string[]): string {
+  if (items.length < 2) return items.join('');
+  if (items.length === 2) return items.join(' and ');
+
+  const firstItems = items.slice(0, items.length - 1);
+  const lastItem = items[items.length - 1];
+  return `${firstItems.join(', ')}, and ${lastItem}`;
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index 8de6ac2..118b7e5 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -16,7 +16,7 @@
  */
 
 import '../test/common-test-setup-karma';
-import {pluralize, ordinal} from './string-util';
+import {pluralize, ordinal, listForSentence} from './string-util';
 
 suite('formatter util tests', () => {
   test('pluralize', () => {
@@ -39,4 +39,11 @@
     assert.equal(ordinal(44413), '44413th');
     assert.equal(ordinal(44451), '44451st');
   });
+
+  test('listForSentence', () => {
+    assert.equal(listForSentence(['Foo', 'Bar', 'Baz']), 'Foo, Bar, and Baz');
+    assert.equal(listForSentence(['Foo', 'Bar']), 'Foo and Bar');
+    assert.equal(listForSentence(['Foo']), 'Foo');
+    assert.equal(listForSentence([]), '');
+  });
 });
diff --git a/polygerrit-ui/app/utils/syntax-util.ts b/polygerrit-ui/app/utils/syntax-util.ts
index 9cba96d..03774bd 100644
--- a/polygerrit-ui/app/utils/syntax-util.ts
+++ b/polygerrit-ui/app/utils/syntax-util.ts
@@ -22,7 +22,7 @@
  * is really that simple:
  * https://github.com/highlightjs/highlight.js/blob/main/src/lib/html_renderer.js
  */
-const openingSpan = new RegExp('<span class="(.*?)">');
+const openingSpan = new RegExp('<span class="([^"]*?)">');
 const closingSpan = new RegExp('</span>');
 
 /**
@@ -38,6 +38,15 @@
     .replace(/&amp;/g, '&');
 }
 
+function equal(r: SyntaxLayerRange) {
+  return (s: SyntaxLayerRange) =>
+    r.start === s.start && r.length === s.length && r.className === s.className;
+}
+
+function unique(r: SyntaxLayerRange, index: number, array: SyntaxLayerRange[]) {
+  return index === array.findIndex(equal(r));
+}
+
 /**
  * HighlightJS produces one long HTML string with HTML elements spanning
  * multiple lines. <gr-diff> is line based, needs all elements closed at the end
@@ -58,13 +67,11 @@
   for (let line of highlightedCode.split('\n')) {
     const ranges: SyntaxLayerRange[] = [...carryOverRanges];
     carryOverRanges = [];
-    rangesPerLine.push({ranges});
 
     // Remove all span tags one after another from left to right.
     // For each opening <span ...> push a new (unclosed) range.
     // For each closing </span> close the latest unclosed range.
     let removal: SpanRemoval | undefined;
-    line = unescapeHTML(line);
     while ((removal = removeFirstSpan(line)) !== undefined) {
       if (removal.type === SpanType.OPENING) {
         ranges.push({
@@ -89,6 +96,9 @@
         range.length = lineLength - range.start;
       }
     }
+    rangesPerLine.push({
+      ranges: ranges.filter(r => r.length > 0).filter(unique),
+    });
   }
   if (carryOverRanges.length > 0) {
     throw new Error('unclosed <span>s in highlighted code');
@@ -102,7 +112,7 @@
 
 function lastUnclosed(ranges: SyntaxLayerRange[]) {
   const unclosed = [...ranges].reverse().find(isUnclosed);
-  if (!unclosed) throw new Error('no unclosed range found');
+  if (!unclosed) throw new Error(`no unclosed range found ${ranges.length}`);
   return unclosed;
 }
 
@@ -137,14 +147,18 @@
   }
   const type =
     openingIndex < closingIndex ? SpanType.OPENING : SpanType.CLOSING;
-  const offset = type === SpanType.OPENING ? openingIndex : closingIndex;
   const match = type === SpanType.OPENING ? openingMatch : closingMatch;
   if (match === null) return undefined;
   const length = match[0].length;
+  const offsetEscaped = type === SpanType.OPENING ? openingIndex : closingIndex;
+  const lineUpToMatch = line.slice(0, offsetEscaped);
+  const lineAfterMatch = line.slice(offsetEscaped + length);
+  // We are parsing HTML, so escaped characters must only count as one char.
+  const offsetUnescaped = unescapeHTML(lineUpToMatch).length;
   const removal: SpanRemoval = {
     type,
-    lineAfter: line.slice(0, offset) + line.slice(offset + length),
-    offset,
+    lineAfter: lineUpToMatch + lineAfterMatch,
+    offset: offsetUnescaped,
     class: type === SpanType.OPENING ? match[1] : undefined,
   };
   return removal;
diff --git a/polygerrit-ui/app/utils/syntax-util_test.ts b/polygerrit-ui/app/utils/syntax-util_test.ts
index fef908a..4d381fb 100644
--- a/polygerrit-ui/app/utils/syntax-util_test.ts
+++ b/polygerrit-ui/app/utils/syntax-util_test.ts
@@ -67,6 +67,28 @@
       );
     });
 
+    test('removal of empty spans', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges('asdf<span class="c"></span>asdf'),
+        [{ranges: []}]
+      );
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '<span class="d"></span>\n<span class="d"></span>'
+        ),
+        [{ranges: []}, {ranges: []}]
+      );
+    });
+
+    test('removal of duplicate spans', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '<span class="d"><span class="d">asdfqwer</span></span>'
+        ),
+        [{ranges: [{start: 0, length: 8, className: 'd'}]}]
+      );
+    });
+
     test('one line, two spans one after another', async () => {
       assert.deepEqual(
         highlightedStringToRanges(
@@ -99,6 +121,49 @@
       );
     });
 
+    test('<span> quoted in a string', async () => {
+      const s = `
+<span class="keyword">const</span> x = <span class="string">&#x27;&lt;span class=&quot;c&quot;&gt;&#x27;</span>;
+<span class="keyword">const</span> y = <span class="string">&#x27;&lt;/span&gt;&#x27;</span>;`;
+
+      assert.deepEqual(highlightedStringToRanges(s), [
+        {ranges: []},
+        {
+          ranges: [
+            {start: 0, length: 5, className: 'keyword'},
+            {start: 10, length: 18, className: 'string'},
+          ],
+        },
+        {
+          ranges: [
+            {start: 0, length: 5, className: 'keyword'},
+            {start: 10, length: 9, className: 'string'},
+          ],
+        },
+      ]);
+    });
+
+    test('one complex line with escaped HTML', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '  <span class="tag">&lt;<span class="name">span</span> <span class="attr">class</span>=<span class="string">&quot;title&quot;</span>&gt;</span>[[name]]<span class="tag">&lt;/<span class="name">span</span>&gt;</span>'
+        ),
+        [
+          {
+            ranges: [
+              // '  <span class="title">[[name]]</span>'
+              {start: 2, length: 20, className: 'tag'},
+              {start: 3, length: 4, className: 'name'},
+              {start: 8, length: 5, className: 'attr'},
+              {start: 14, length: 7, className: 'string'},
+              {start: 30, length: 7, className: 'tag'},
+              {start: 32, length: 4, className: 'name'},
+            ],
+          },
+        ]
+      );
+    });
+
     test('two lines, one span each', async () => {
       assert.deepEqual(
         highlightedStringToRanges(
diff --git a/polygerrit-ui/app/utils/type-util.ts b/polygerrit-ui/app/utils/type-util.ts
new file mode 100644
index 0000000..e91fefc
--- /dev/null
+++ b/polygerrit-ui/app/utils/type-util.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Gets all properties of a Source that match a given Type. For example:
+ *
+ *   type BooleansOfHTMLElement = PropertiesOfType<HTMLElement, boolean>;
+ *
+ * will be 'draggable' | 'autofocus' | etc.
+ */
+export type PropertiesOfType<Source, Type> = {
+  [K in keyof Source]: Source[K] extends Type ? K : never;
+}[keyof Source];
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index de6462f..1578bfb 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -24,6 +24,12 @@
   return window.CANONICAL_PATH || '';
 }
 
+export function prependOrigin(path: string): string {
+  if (path.startsWith('http')) return path;
+  if (path.startsWith('/')) return window.location.origin + path;
+  throw new Error(`Cannot prepend origin to relative path '${path}'.`);
+}
+
 let getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
 
 /**
diff --git a/polygerrit-ui/app/utils/worker-util.ts b/polygerrit-ui/app/utils/worker-util.ts
index 933744f..4ae41ff 100644
--- a/polygerrit-ui/app/utils/worker-util.ts
+++ b/polygerrit-ui/app/utils/worker-util.ts
@@ -16,6 +16,8 @@
 }
 
 export function createWorker(workerUrl: string): Worker {
+  if (!workerUrl.startsWith('http'))
+    throw new Error(`Worker URL '${workerUrl}' does not start with 'http'.`);
   return new Worker(wrapUrl(workerUrl));
 }
 
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 5541ef4..e0b18be 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,10 +2,10 @@
 # yarn lockfile v1
 
 
-"@lit/reactive-element@^1.1.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.2.0.tgz#c62444a0e3d3f8d3a6875ad56f867279aa89fa88"
-  integrity sha512-7i/Fz8enAQ2AN5DyJ2i2AFERufjP6x1NjuHoNgDyJkjjHxEoo8kVyyHxu1A9YyeShlksjt5FvpvENBDuivQHLA==
+"@lit/reactive-element@^1.3.0":
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.3.2.tgz#43e470537b6ec2c23510c07812616d5aa27a17cd"
+  integrity sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==
 
 "@mapbox/node-pre-gyp@^1.0.0":
   version "1.0.5"
@@ -517,10 +517,10 @@
   resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
   integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
 
-codemirror-minified@^5.62.2:
-  version "5.63.0"
-  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.63.0.tgz#29d1a78713a633c933a27853679afdc0bfea49cc"
-  integrity sha512-dMN2w0Qg5Zwn2p7UW3sYAoyrJ+QRBkiF5bfbQAvQ1bfqhEjGnZ++/zvOG7NivfnUbYRhSULz8lsFtzt4ldBNyQ==
+codemirror-minified@^5.65.0:
+  version "5.65.0"
+  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.65.0.tgz#283f21655d6fc3477e64532c86a657bbc2063c19"
+  integrity sha512-AxpxR5XolsvgAjwE1BspomW6fhj541BxMyj0HT5TmeketKJ/kPSEiTZes/cQgHvHOmGB4clbR67Mz/ORrjYkMQ==
 
 concat-map@0.0.1:
   version "0.0.1"
@@ -604,7 +604,7 @@
   resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
   integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
 
-highlight.js@^10.4.1, highlight.js@^10.7.2:
+highlight.js@^10.4.1:
   version "10.7.3"
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
   integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
@@ -614,6 +614,11 @@
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.3.1.tgz#813078ef3aa519c61700f84fe9047231c5dc3291"
   integrity sha512-PUhCRnPjLtiLHZAQ5A/Dt5F8cWZeMyj9KRsACsWT+OD6OP0x6dp5OmT5jdx0JgEyPxPZZIPQpRN2TciUT7occw==
 
+highlight.js@^11.5.0:
+  version "11.5.0"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.5.0.tgz#00abb7ed926491adbdabc93a4f3fd2b88b451b4a"
+  integrity sha512-SM6WDj5/C+VfIY8pZ6yW6Xa0Fm1tniYVYWYW1Q/DcMnISZFrC3aQAZZZFAAZtybKNrGId3p/DNbFTtcTXXgYBw==
+
 "highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
   version "0.0.1"
   resolved "https://github.com/highlightjs/highlightjs-closure-templates#2ac39a4a0ddf08dc826e341ababf3d00fd69878a"
@@ -674,29 +679,29 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
-lit-element@^3.1.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.1.1.tgz#562d5ccbc8ba0c01d8ba4a0ac3576263167d2ccb"
-  integrity sha512-14ClnMAU8EXnzC+M2/KDd3SFmNUn1QUw1+GxWkEMwGV3iaH8ObunMlO5svzvaWlkSV0WlxJCi40NGnDVJ2XZKQ==
+lit-element@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7"
+  integrity sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==
   dependencies:
-    "@lit/reactive-element" "^1.1.0"
-    lit-html "^2.1.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-html "^2.2.0"
 
-lit-html@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.1.1.tgz#f4da485798a0d967514d31730d387350fafb79f7"
-  integrity sha512-E4BImK6lopAYanJpvcGaAG8kQFF1ccIulPu2BRNZI7acFB6i4ujjjsnaPVFT1j/4lD9r8GKih0Y8d7/LH8SeyQ==
+lit-html@^2.2.0:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.3.tgz#dcb2744d0f0c1800b2eb2de37bc42384434a74f7"
+  integrity sha512-vI4j3eWwtQaR8q/O63juZVliBIFMio716X719/lSsGH4UWPy2/7Qf377jsNs4cx3gCHgIbx8yxFgXFQ/igZyXQ==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.1.1.tgz#65f43abca945988f696391f762c645ba51966b0b"
-  integrity sha512-yqDqf36IhXwOxIQSFqCMgpfvDCRdxLCLZl7m/+tO5C9W/OBHUj17qZpiMBT35v97QMVKcKEi1KZ3hZRyTwBNsQ==
+lit@^2.2.3:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.3.tgz#77203d8f247de7c0d4955817f89e40c927349b9c"
+  integrity sha512-5/v+r9dH3Pw/o0rhp/qYk3ERvOUclNF31bWb0FiW6MPgwdQIr+/KCt/p3zcd8aPl8lIGnxdGrVcZA+gWS6oFOQ==
   dependencies:
-    "@lit/reactive-element" "^1.1.0"
-    lit-element "^3.1.0"
-    lit-html "^2.1.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-element "^3.2.0"
+    lit-html "^2.2.0"
 
 lru-cache@^6.0.0:
   version "6.0.0"
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 77d93c3..cdc03aa 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -4,12 +4,11 @@
   "browser": true,
   "dependencies": {
     "@types/chai": "^4.2.16",
-    "@types/lodash": "^4.14.168",
     "@types/mocha": "^8.2.2",
     "@types/sinon": "^10.0.0"
   },
   "devDependencies": {
-    "@open-wc/karma-esm": "^2.16.16",
+    "@open-wc/karma-esm": "^3.0.9",
     "@open-wc/semantic-dom-diff": "^0.19.5",
     "@open-wc/testing-helpers": "^2.0.2",
     "@polymer/iron-test-helpers": "^3.0.1",
@@ -20,11 +19,10 @@
     "karma-chrome-launcher": "^3.1.0",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
-    "lodash": "^4.17.21",
     "mocha": "8.3.2",
     "sinon": "^10.0.0",
     "source-map-support": "^0.5.19"
   },
   "license": "Apache-2.0",
   "private": true
-}
+}
\ No newline at end of file
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index c148f3e..44dd946 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -929,7 +929,7 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
-"@open-wc/building-utils@^2.18.0", "@open-wc/building-utils@^2.18.3":
+"@open-wc/building-utils@^2.18.3":
   version "2.18.4"
   resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.18.4.tgz#397e42039f5d26c38f7a2cc01e347e0e5c2e8e99"
   integrity sha512-wjNp9oE1SFsiBEqaI67ff60KHDpDbGMNF+82pvCHe412SFY4q8DNy8A+hesj1nZsuZHH1/olDfzBDbYKAnmgMg==
@@ -966,19 +966,19 @@
   resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.0.tgz#0df5d438285fc3482838786ee81895318f0ff778"
   integrity sha512-UfdK1MPnR6T7f3svzzYBfu3qBkkZ/KsPhcpc3JYhsUY4hbpwNF9wEQtD4Z+/mRqMTJrKg++YSxIxE0FBhY3RIw==
 
-"@open-wc/karma-esm@^2.16.16":
-  version "2.16.18"
-  resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-2.16.18.tgz#01f3f8c694d7b8dd3aef3159659f3fa9d3090c44"
-  integrity sha512-K+HeXqRdvOupnlRr7rHgBFSqgyr5E0KNS89BTnHN0qKcDpa+M+m1sZHInPrB+iFqLQ7hhWNeGmtesDFfnIBhaQ==
+"@open-wc/karma-esm@^3.0.9":
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-3.0.9.tgz#f2bb5c78f69685289718097b8d9cd742acfb07fe"
+  integrity sha512-GzpL/iHVBskZDmKC7cLYLBYzuSoIng7CxyMxp5Ai4VxMSwCVFG+6j/PKLOXVma+EAwwvmq2L5B9tWVbXR34Peg==
   dependencies:
-    "@open-wc/building-utils" "^2.18.0"
+    "@open-wc/building-utils" "^2.18.3"
     babel-plugin-istanbul "^5.1.4"
     chokidar "^3.0.0"
     deepmerge "^4.2.2"
-    es-dev-server "^1.57.0"
+    es-dev-server "^1.57.8"
     minimatch "^3.0.4"
     node-fetch "^2.6.0"
-    polyfills-loader "^1.6.1"
+    polyfills-loader "^1.7.4"
     portfinder "^1.0.21"
     request "^2.88.0"
 
@@ -1343,11 +1343,6 @@
   dependencies:
     "@types/koa" "*"
 
-"@types/lodash@^4.14.168":
-  version "4.14.172"
-  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a"
-  integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw==
-
 "@types/lru-cache@^5.1.0":
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef"
@@ -2396,7 +2391,7 @@
   resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.3.2.tgz#cab2c7c83e199a2b2862de3fea46f68372094166"
   integrity sha512-cJp8qf5t2cXmVZJjZVrcU4ODFJeQOcUyjJEtPFtWO+3N6JPM6vCe4Sfv3cwIs/qS7gnUo/fvKX/mDCVQZq+P7A==
 
-es-dev-server@^1.57.0:
+es-dev-server@^1.57.8:
   version "1.60.2"
   resolved "https://registry.yarnpkg.com/es-dev-server/-/es-dev-server-1.60.2.tgz#cca56fe452d46c3ec531c19745e0aa9d7b71e1b3"
   integrity sha512-Lp9kZzawJ35HDKiqLNb/YbD2VufF+3tdxHgbP/kfdLI5JLgDJV4SuKTWWny3ZuBUAlZKGre7a0iXUByGQqfdPA==
@@ -3915,7 +3910,7 @@
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
   integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
 
-polyfills-loader@^1.6.1, polyfills-loader@^1.7.4:
+polyfills-loader@^1.7.4:
   version "1.7.6"
   resolved "https://registry.yarnpkg.com/polyfills-loader/-/polyfills-loader-1.7.6.tgz#5cff98bfc9689cf10e44bdd32f498cfeb4374c51"
   integrity sha512-AiLIgmGFmzcvsqewyKsqWb7H8CnWNTSQBoM0u+Mauzmp0DsjObXmnZdeqvTn0HNwc1wYHHTOta82WjSjG341eQ==
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 10f003e..8c97a49 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -95,7 +95,6 @@
         <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
       {/if}
     {/if}
-    <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script" crossorigin="anonymous"/>
     <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/robotcomments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {if $userIsAuthenticated}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index e7fda5a..1399b15 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -51,7 +51,7 @@
 
 usage() {
     me=`basename "$0"`
-    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site]"
+    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug_port|--debug_address ...] [--suspend]]"
     exit 1
 }
 
@@ -148,6 +148,27 @@
     GERRIT_SITE=${1##--site-path=}
     shift
     ;;
+  --debug)
+    JVM_DEBUG=true
+    shift
+    ;;
+  --suspend)
+    JVM_DEBUG_SUSPEND=true
+    shift
+    ;;
+  --debug-port=*)
+    DEBUG_ADDRESS=${1##--debug-port=}
+    shift
+    ;;
+  --debug-address=*)
+    DEBUG_ADDRESS=${1##--debug-address=}
+    shift
+    ;;
+  --debug-port|--debug-address)
+    shift
+    DEBUG_ADDRESS=$1
+    shift
+    ;;
 
   *)
     usage
@@ -317,6 +338,20 @@
   JAVA_OPTIONS="$JAVA_OPTIONS -Xmx$GERRIT_MEMORY"
 fi
 
+if test -n "$JVM_DEBUG" ; then
+  if test -z "$DEBUG_ADDRESS" ; then
+    DEBUG_ADDRESS=8000
+  fi
+  echo "Put JVM in debug mode, debugger listens to: $DEBUG_ADDRESS"
+  if test -n "$JVM_DEBUG_SUSPEND" ; then
+    SUSPEND=y
+    echo "JVM will await for a debugger to attach"
+  else
+    SUSPEND=n
+  fi
+  JAVA_OPTIONS="$JAVA_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=$SUSPEND,address=$DEBUG_ADDRESS"
+fi
+
 GERRIT_FDS=`get_config --int core.packedGitOpenFiles`
 test -z "$GERRIT_FDS" && GERRIT_FDS=128
 FDS_MULTIPLIER=2
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 28d04d0..ba422a4 100644
--- a/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -25,10 +25,13 @@
   {@param change: ?}
   {@param shortProjectName: ?}
   {@param instanceAndProjectName: ?}
-  {@param addInstanceNameInSubject: ?}  /** boolean */
+  {@param addInstanceNameInSubject: ?}    /** boolean */
+
   {if not $addInstanceNameInSubject}
-    Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
+    [{$change.sizeBucket}] Change in {$shortProjectName}[{$branch.shortName}]: {$change
+    .shortSubject}
   {else}
-    Change in {$instanceAndProjectName}[{$branch.shortName}]: {$change.shortSubject}
+    [{$change.sizeBucket}] Change in {$instanceAndProjectName}[{$branch.shortName}]: {$change
+    .shortSubject}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 4b66401..98ab4b2 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -26,8 +26,34 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param commentFiles: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {$fromName} has posted comments on this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index a120cea..320122e 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -25,6 +25,9 @@
   {@param labels: ?}
   {@param patchSet: ?}
   {@param patchSetCommentBlocks: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
@@ -99,6 +102,31 @@
     </p>
   {/if}
 
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {if $email.changeUrl}
     <p>
       {call mailTemplate.ViewChangeButton data="all" /}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index 2647572..6ae8625 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -27,6 +27,9 @@
   {@param fromName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
     Hello{sp}
     {for $reviewerName in $email.reviewerNames}
@@ -50,6 +53,40 @@
     {/if}.
     {if $email.changeUrl} ( {$email.changeUrl} ){/if}
   {/if}{\n}
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    {\n}
+    The following approvals got outdated and were removed:{\n}
+    {for $outdatedApproval, $index in $email.outdatedApprovals}
+      {if $index > 0}
+        ,{sp}
+      {/if}
+      {$outdatedApproval}
+    {/for}{\n}
+  {/if}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
+  {\n}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 4916a4a..1d99591 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -25,6 +25,9 @@
   {@param fromEmail: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
@@ -41,6 +44,43 @@
     </p>
   {/if}
 
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    <p>
+      The following approvals got outdated and were removed:{\n}
+      {for $outdatedApproval, $index in $email.outdatedApprovals}
+        {if $index > 0}
+          ,{sp}
+        {/if}
+        {$outdatedApproval}
+      {/for}
+    </p>
+  {/if}
+
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {call mailTemplate.Pre}
     {param content: $email.changeDetail /}
   {/call}
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 43e9b3e..c9ac0fe 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -22,6 +22,7 @@
     "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
+    "//lib/log:impl-log4j",
     "//prolog:gerrit-prolog-common",
     "//resources:log4j-config",
 ]
diff --git a/tools/deps.bzl b/tools/deps.bzl
index d243957..3138d15 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -3,13 +3,14 @@
 
 CAFFEINE_VERS = "2.9.2"
 ANTLR_VERS = "3.5.2"
-SLF4J_VERS = "1.7.33"
 COMMONMARK_VERS = "0.10.0"
 FLEXMARK_VERS = "0.50.50"
 GREENMAIL_VERS = "1.5.5"
 MAIL_VERS = "1.6.0"
 MIME4J_VERS = "0.8.1"
 OW2_VERS = "9.2"
+AUTO_COMMON_VERSION = "1.2.1"
+AUTO_FACTORY_VERSION = "1.0.1"
 AUTO_VALUE_VERSION = "1.7.4"
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
@@ -63,16 +64,11 @@
 
     maven_jar(
         name = "servlet-api",
-        artifact = "org.apache.tomcat:tomcat-servlet-api:8.5.23",
-        sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
+        artifact = "javax.servlet:javax.servlet-api:3.1.0",
+        sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
     )
 
     # JGit's transitive dependencies
-    maven_jar(
-        name = "hamcrest",
-        artifact = "org.hamcrest:hamcrest:2.2",
-        sha1 = "1820c0968dba3a11a1b30669bb1f01978a91dedc",
-    )
 
     maven_jar(
         name = "javaewah",
@@ -81,16 +77,10 @@
         sha1 = "9feecc2b24d6bc9ff865af8d082f192238a293eb",
     )
 
-    # TODO(davido): Switch to official release once available.
-    # Use custom release that fixed compatibility with JDK 17:
-    # https://github.com/google/gson/issues/1875
-    java_import_external(
+    maven_jar(
         name = "gson",
-        jar_sha256 = "d68e2a0f4b97143988f2ceef593947acc3f9d9e9618569c26264e63179887d49",
-        jar_urls = [
-            "https://github.com/davido/gson/releases/download/v2.9.0/gson-2.9.0.jar",
-        ],
-        licenses = ["unencumbered"],  # public domain
+        artifact = "com.google.code.gson:gson:2.9.0",
+        sha1 = "8a1167e089096758b49f9b34066ef98b2f4b37aa",
     )
 
     maven_jar(
@@ -112,24 +102,6 @@
     )
 
     maven_jar(
-        name = "log-api",
-        artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
-        sha1 = "d375aa1b98d34d5ddf73a3f19eaad66e98975b12",
-    )
-
-    maven_jar(
-        name = "log-ext",
-        artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
-        sha1 = "00da03640ae1ad57f964dcaa542fb5d804dce8a6",
-    )
-
-    maven_jar(
-        name = "jcl-over-slf4j",
-        artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
-        sha1 = "28c441128bc81b6d95cc2857ae5bb46ae5bf658b",
-    )
-
-    maven_jar(
         name = "json-smart",
         artifact = "net.minidev:json-smart:1.1.1",
         sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
@@ -441,6 +413,24 @@
     )
 
     maven_jar(
+        name = "auto-common",
+        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+        sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
+    )
+
+    maven_jar(
+        name = "auto-factory",
+        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+    )
+
+    maven_jar(
+        name = "auto-service-annotations",
+        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+    )
+
+    maven_jar(
         name = "auto-value",
         artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
         sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index cd9f132..c1d8095 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -44,12 +44,17 @@
     name = "autovalue_classpath_collect",
     deps = [
         "//lib/auto:auto-value",
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
         "@auto-value-annotations//jar",
         "@auto-value-gson-extension//jar",
         "@auto-value-gson-factory//jar",
         "@auto-value-gson-runtime//jar",
         "@autotransient//jar",
         "@gson//jar",
+        "@guava//jar",
         "@javapoet//jar",
+        "@javax_inject//jar",
     ],
 )
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
index da77234..6c645f4 100644
--- a/tools/js/template_checker.bzl
+++ b/tools/js/template_checker.bzl
@@ -123,9 +123,7 @@
     )
 
     # Pack all transformed files. Later files can be materialized in the
-    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
-    # automatically
-    # npm run polytest:dev
+    # WORKSPACE/polygerrit-ui/app/tmpl_out dir.
     pkg_tar(
         name = name + "_tar",
         srcs = generated_dev_files,
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
index b25656d..eacb02b 100644
--- a/tools/maven/package.bzl
+++ b/tools/maven/package.bzl
@@ -40,7 +40,7 @@
         src = {},
         doc = {},
         war = {}):
-    build_cmd = ["bazel_cmd", "build"]
+    build_cmd = ["bazel_cmd", "build", "--java_toolchain=//tools:error_prone_warnings_toolchain_java11"]
     mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version]
     api_cmd = mvn_cmd[:]
     api_targets = []
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 669ab8d..3f9d3a4 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -2385,7 +2385,7 @@
   resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005"
   integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==
 
-cacheable-request@^7.0.1:
+cacheable-request@^7.0.2:
   version "7.0.2"
   resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27"
   integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==
@@ -4309,16 +4309,16 @@
   integrity sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg==
 
 got@^11.8.2:
-  version "11.8.2"
-  resolved "https://registry.yarnpkg.com/got/-/got-11.8.2.tgz#7abb3959ea28c31f3576f1576c1effce23f33599"
-  integrity sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==
+  version "11.8.3"
+  resolved "https://registry.yarnpkg.com/got/-/got-11.8.3.tgz#f496c8fdda5d729a90b4905d2b07dbd148170770"
+  integrity sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==
   dependencies:
     "@sindresorhus/is" "^4.0.0"
     "@szmarczak/http-timer" "^4.0.5"
     "@types/cacheable-request" "^6.0.1"
     "@types/responselike" "^1.0.0"
     cacheable-lookup "^5.0.3"
-    cacheable-request "^7.0.1"
+    cacheable-request "^7.0.2"
     decompress-response "^6.0.0"
     http2-wrapper "^1.0.0-beta.5.2"
     lowercase-keys "^2.0.0"
@@ -8597,11 +8597,16 @@
   resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
-tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.8.1:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
+tslib@^1.9.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
+  integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
+
 tsutils@3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 612d897..d8f7020 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -18,8 +18,34 @@
 
     maven_jar(
         name = "log4j",
-        artifact = "ch.qos.reload4j:reload4j:1.2.18.1",
-        sha1 = "7075022a11e18c1ad230de5be074e0c691fed17b",
+        artifact = "ch.qos.reload4j:reload4j:1.2.19",
+        sha1 = "4eae9978468c5e885a6fb44df7e2bbc07a20e6ce",
+    )
+
+    SLF4J_VERS = "1.7.36"
+
+    maven_jar(
+        name = "log-api",
+        artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
+        sha1 = "6c62681a2f655b49963a5983b8b0950a6120ae14",
+    )
+
+    maven_jar(
+        name = "log-ext",
+        artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
+        sha1 = "99f282aea4b6dbca04d00f0ade6e5ed61ee7091a",
+    )
+
+    maven_jar(
+        name = "impl-log4j",
+        artifact = "org.slf4j:slf4j-reload4j:" + SLF4J_VERS,
+        sha1 = "db708f7d959dee1857ac524636e85ecf2e1781c1",
+    )
+
+    maven_jar(
+        name = "jcl-over-slf4j",
+        artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
+        sha1 = "d877e195a05aca4a2f1ad2ff14bfec1393af4b5e",
     )
 
     maven_jar(
@@ -248,3 +274,10 @@
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
         sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
     )
+
+    # JGit's transitive dependencies
+    maven_jar(
+        name = "hamcrest",
+        artifact = "org.hamcrest:hamcrest:2.2",
+        sha1 = "1820c0968dba3a11a1b30669bb1f01978a91dedc",
+    )
diff --git a/yarn.lock b/yarn.lock
index e6fa177..406f567 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -38,9 +38,9 @@
     regenerator-runtime "^0.13.4"
 
 "@bazel/concatjs@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.1.0.tgz#f4321dec4a225c3ceac41b2dc7ec7c3dd3dd5e21"
-  integrity sha512-sj+vxHVB/swh7awOfQ37h3p/gxSPgLSnUkDt6POrj26qkfi7HrLB1ZkWAPFIIxjEhsBp1LchoHiezjw2GylZQg==
+  version "5.4.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.4.0.tgz#04e752a6ea3e684f00879e6683657c4ede72df6e"
+  integrity sha512-jlupaDKxqFS3B1lttOIgkKxirP7v5Qx7KCFtOXO7JxtvYJD/qKtKXEQggTrGKJqLPyiZlNiYimHHGICLSWIZcQ==
   dependencies:
     protobufjs "6.8.8"
     source-map-support "0.5.9"